fix: complete subagent output and file workspace interactions
OpenCode child cards now show ordered text, reasoning and tool progress, with the final result displayed separately. Local SDK and remote ACP paths share child tracking, including progress retained across recovery and cancellation. Remote live progress requires an Agent package containing the event plugin; older Agent packages still expose final results without inventing missing history. Knowledge-library rows remain clickable when native label activation changes focus. File workspace changes use colored Git status letters and per-file staged/unstaged diffs, including deleted, renamed and untracked files. Refreshing reloads the selected diff and expanded directories, and more than 50 changes remain accessible through incremental loading. Validated with 3552 passing tests, typecheck, lint, desktop build, current-source Linux Host scenarios and isolated Windows desktop UI interactions. Release note: 展开 OpenCode 子代理卡片可查看执行过程、工具详情与独立最终结果;知识库支持整行勾选。工作区支持单文件暂存与未暂存差异,刷新会更新当前差异,更多变更可继续加载。远端实时过程需要更新 GoodBuddy Agent。
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { spawn as nodeSpawn } from 'node:child_process'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
import {
|
||||
createServer,
|
||||
type IncomingHttpHeaders,
|
||||
@@ -8,7 +12,7 @@ import {
|
||||
type ServerResponse
|
||||
} from 'node:http'
|
||||
import type { Socket } from 'node:net'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import { isAbsolute, resolve, join } from 'node:path'
|
||||
import type {
|
||||
ModelBridgeModelProtocol
|
||||
} from '../shared/model-bridge-contracts'
|
||||
@@ -409,7 +413,11 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
const exchange = createUnixModelBridgeExchange({ socketPath })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
||||
const origin = await proxy.listen()
|
||||
let pluginDirectory: string | undefined
|
||||
try {
|
||||
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
||||
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
@@ -419,7 +427,10 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
JSON.stringify(config)
|
||||
JSON.stringify({
|
||||
...config,
|
||||
plugin: [pathToFileURL(pluginPath).href]
|
||||
})
|
||||
)
|
||||
const child = (options.spawn ?? defaultHelperSpawn)(
|
||||
opencodeEntrypoint,
|
||||
@@ -442,6 +453,9 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
})
|
||||
} finally {
|
||||
await proxy.close()
|
||||
if (pluginDirectory) {
|
||||
await rm(pluginDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
|
||||
const write = vi.fn()
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource().replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } } }
|
||||
)
|
||||
const hooks = await factory()
|
||||
const event = {
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'parent',
|
||||
part: {
|
||||
type: 'tool', tool: 'task', callID: 'task',
|
||||
state: { metadata: { sessionId: 'child' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
await hooks.event({ event })
|
||||
await hooks.event({ event: {
|
||||
type: 'message.part.delta', properties: { sessionID: 'child', delta: 'Inspecting' }
|
||||
} })
|
||||
await hooks.event({ event: {
|
||||
type: 'message.part.delta', properties: { sessionID: 'unrelated', delta: 'Private' }
|
||||
} })
|
||||
expect(write).toHaveBeenCalledTimes(2)
|
||||
for (const [line] of write.mock.calls) {
|
||||
expect(line.endsWith('\n')).toBe(true)
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
method: 'session/update',
|
||||
params: { sessionId: 'parent', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'task',
|
||||
_meta: { goodbuddySubagentEvent: { properties: expect.any(Object) } }
|
||||
} }
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* OpenCode's ACP adapter omits native child sessions. This plugin forwards
|
||||
* their SDK events as ACP metadata on the owning Task, using the same stdio
|
||||
* stream and prompt lifetime as the built-in adapter.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(): string {
|
||||
return `export default ${plugin.toString()}`
|
||||
}
|
||||
|
||||
function plugin() {
|
||||
const tasks = new Map<string, { sessionId: string; callId: string }>()
|
||||
return Promise.resolve({
|
||||
event: async ({ event }: { event: {
|
||||
type: string
|
||||
properties: {
|
||||
sessionID?: string
|
||||
part?: {
|
||||
type: string
|
||||
tool?: string
|
||||
callID?: string
|
||||
state?: {
|
||||
metadata?: { sessionId?: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
} }) => {
|
||||
const properties = event.properties
|
||||
const part = properties.part
|
||||
let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined
|
||||
if (
|
||||
!owner && event.type === 'message.part.updated' &&
|
||||
part?.type === 'tool' && part.tool === 'task' &&
|
||||
part.callID && properties.sessionID
|
||||
) {
|
||||
owner = { sessionId: properties.sessionID, callId: part.callID }
|
||||
const child = part.state?.metadata?.sessionId
|
||||
if (child) tasks.set(child, owner)
|
||||
}
|
||||
if (!owner || ![
|
||||
'message.updated', 'message.part.updated', 'message.part.delta'
|
||||
].includes(event.type)) return
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: owner.sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: owner.callId,
|
||||
_meta: { goodbuddySubagentEvent: event }
|
||||
}
|
||||
}
|
||||
}) + '\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -127,6 +127,32 @@ describe('WorkspaceGitService', () => {
|
||||
code: 'git-unsafe'
|
||||
})
|
||||
})
|
||||
|
||||
linuxIt('diffs unborn, untracked, staged, renamed and deleted files by literal path', async () => {
|
||||
const root = temporaryDirectory()
|
||||
const executable = await resolveVerifiedGitExecutable()
|
||||
git(executable, root, 'init', '--quiet')
|
||||
const service = new WorkspaceGitService({ gitExecutable: executable })
|
||||
const access = accessFor(root)
|
||||
const diff = (relativePath: string, staged = false) => service.diff(access, {
|
||||
workspaceId: 'workspace-a', generation: 1, relativePath, staged, maximumBytes: 4096
|
||||
})
|
||||
writeFileSync(join(root, '[new].txt'), 'original\n')
|
||||
expect((await diff('[new].txt')).patch).toContain('+original')
|
||||
expect((await diff('[new].txt', true)).patch).toBe('')
|
||||
git(executable, root, 'add', '.')
|
||||
expect((await diff('[new].txt', true)).patch).toContain('+original')
|
||||
writeFileSync(join(root, '[new].txt'), 'edited\n')
|
||||
expect((await diff('[new].txt')).patch).toContain('+edited')
|
||||
git(executable, root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
|
||||
git(executable, root, 'mv', '[new].txt', 'renamed file.txt')
|
||||
expect((await diff('renamed file.txt', true)).patch).toContain('rename from [new].txt')
|
||||
expect((await diff('renamed file.txt')).patch).toContain('+edited')
|
||||
rmSync(join(root, 'renamed file.txt'))
|
||||
expect((await diff('renamed file.txt')).patch).toContain('-original')
|
||||
git(executable, root, 'add', '-u')
|
||||
expect((await diff('[new].txt', true)).patch).toContain('-original')
|
||||
})
|
||||
})
|
||||
|
||||
function git(executable: string, cwd: string, ...args: string[]): void {
|
||||
|
||||
@@ -184,8 +184,23 @@ export class WorkspaceGitService {
|
||||
options: WorkspaceIoOptions = {}
|
||||
): Promise<RemoteGitDiffResult> {
|
||||
const repository = await this.#repository(accessService, options)
|
||||
if (request.relativePath !== undefined && request.relativePath !== '') {
|
||||
await accessService.stat(request.relativePath, options)
|
||||
const selectedPath = request.relativePath || undefined
|
||||
const status = selectedPath === undefined ? undefined : await this.status(
|
||||
accessService,
|
||||
{ workspaceId: request.workspaceId, generation: request.generation,
|
||||
includeIgnored: false, maximumEntries: 2000 },
|
||||
options
|
||||
)
|
||||
const file = status?.entries.find((entry) => entry.relativePath === selectedPath)
|
||||
if (selectedPath !== undefined && !file) {
|
||||
throw new WorkspaceServiceError('File is no longer in the Git changes list', 'git-failed')
|
||||
}
|
||||
const untracked = file?.index === 'untracked'
|
||||
if (untracked && request.staged) {
|
||||
return { repositoryIdentity: repository.repositoryIdentity, patch: '', byteLength: 0, truncated: false }
|
||||
}
|
||||
if (untracked) {
|
||||
await accessService.stat(selectedPath!, options)
|
||||
}
|
||||
const cursor = parseCursor(request.cursor)
|
||||
if (cursor > MAXIMUM_GIT_PROCESS_BYTES) {
|
||||
@@ -205,13 +220,14 @@ export class WorkspaceGitService {
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-color',
|
||||
'--no-renames',
|
||||
'--ignore-submodules=all',
|
||||
...(request.staged ? ['--cached'] : []),
|
||||
...(request.relativePath === undefined ||
|
||||
request.relativePath === ''
|
||||
? []
|
||||
: ['--', request.relativePath])
|
||||
...(untracked ? ['--no-index', '--', NULL_DEVICE, selectedPath!] : [
|
||||
...(request.staged ? ['--cached'] : []),
|
||||
...(selectedPath === undefined ? [] : ['--',
|
||||
...[...new Set([file?.originalRelativePath, selectedPath])]
|
||||
.filter((path): path is string => path !== undefined)
|
||||
.map((path) => `:(literal)${path}`)])
|
||||
])
|
||||
]
|
||||
if (cursor > 0) {
|
||||
const cached = this.#diffSnapshots.get(snapshotKey)
|
||||
@@ -228,7 +244,7 @@ export class WorkspaceGitService {
|
||||
cached === undefined ||
|
||||
cached.repositoryIdentity !== repository.repositoryIdentity ||
|
||||
current === undefined ||
|
||||
current.exitCode !== 0 ||
|
||||
(current.exitCode !== 0 && !(untracked && current.exitCode === 1)) ||
|
||||
current.truncated ||
|
||||
!current.stdout.equals(cached.bytes) ||
|
||||
cursor > cached.bytes.byteLength
|
||||
@@ -254,7 +270,7 @@ export class WorkspaceGitService {
|
||||
options,
|
||||
MAXIMUM_GIT_PROCESS_BYTES
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.exitCode !== 0 && !(untracked && result.exitCode === 1)) {
|
||||
throw gitFailure(result)
|
||||
}
|
||||
const middle = await repositorySnapshot(repository, accessService)
|
||||
@@ -266,7 +282,7 @@ export class WorkspaceGitService {
|
||||
MAXIMUM_GIT_PROCESS_BYTES
|
||||
)
|
||||
if (
|
||||
verification.exitCode !== 0 ||
|
||||
(verification.exitCode !== 0 && !(untracked && verification.exitCode === 1)) ||
|
||||
verification.truncated !== result.truncated ||
|
||||
!verification.stdout.equals(result.stdout)
|
||||
) {
|
||||
@@ -876,6 +892,7 @@ function parseStatus(
|
||||
continue
|
||||
}
|
||||
entries.push({
|
||||
statusCode,
|
||||
relativePath: remoteRelativePathSchema.parse(relativePath),
|
||||
index: mapStatus(statusCode[0]!, statusCode),
|
||||
worktree: mapStatus(statusCode[1]!, statusCode),
|
||||
|
||||
@@ -1544,6 +1544,58 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps ACP content results and bridged child progress in the same card', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const input = { subagent_type: 'general', prompt: 'Read seed' }
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
for (const event of [
|
||||
{ type: 'message.part.updated', properties: {
|
||||
sessionID: sessionId, part: {
|
||||
id: 'task', callID: 'task', type: 'tool', tool: 'task',
|
||||
state: { status: 'running', input, metadata: { sessionId: 'child' } }
|
||||
}
|
||||
} },
|
||||
{ type: 'message.part.delta', properties: {
|
||||
sessionID: 'child', partID: 'text', field: 'text', delta: 'Inspecting seed'
|
||||
} },
|
||||
{ type: 'message.part.updated', properties: {
|
||||
sessionID: 'child', part: {
|
||||
id: 'read', callID: 'read', type: 'tool', tool: 'read',
|
||||
state: { status: 'completed', input: { path: 'seed.txt' }, output: 'seed' }
|
||||
}
|
||||
} }
|
||||
]) {
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'task',
|
||||
_meta: { goodbuddySubagentEvent: event }
|
||||
}
|
||||
})
|
||||
}
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId, update: {
|
||||
sessionUpdate: 'tool_call', toolCallId: 'task', title: 'Read seed', rawInput: input,
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: '**Final seed**' } }]
|
||||
}
|
||||
})
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
client.value = server.client
|
||||
const events = await collect(runtime(server).run(request, new AbortController().signal))
|
||||
const children = events.filter(event => event.type === 'subagent')
|
||||
expect(children.at(-1)).toMatchObject({
|
||||
state: 'completed', output: '**Final seed**',
|
||||
progress: [
|
||||
{ type: 'text', content: 'Inspecting seed' },
|
||||
{ type: 'tool', tool: { name: 'read', state: 'completed', output: 'seed' } }
|
||||
]
|
||||
})
|
||||
expect(events.some(event => event.type === 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds native subagent output per event without a request-wide stop', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from './remote-runtime-channel'
|
||||
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
|
||||
import {
|
||||
OpenCodeSubagentProgress,
|
||||
parseOpenCodeSubagentInput,
|
||||
toOpenCodeSubagentEvent
|
||||
} from './opencode-subagent'
|
||||
@@ -136,6 +137,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
requestId: string
|
||||
operationId: string
|
||||
sessionId: string
|
||||
@@ -153,6 +155,7 @@ type ActivePrompt = {
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
rawInput?: unknown
|
||||
rawOutput?: unknown
|
||||
contentOutput?: string
|
||||
retainedInput?: string
|
||||
retainedOutput?: string
|
||||
retainedError?: string
|
||||
@@ -2067,6 +2070,34 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt: ActivePrompt,
|
||||
update: SessionUpdate
|
||||
): RuntimePublicEvent | undefined {
|
||||
if (this.options.runtimeId === 'opencode') {
|
||||
if (!prompt.subagentProgress) {
|
||||
prompt.subagentProgress = new OpenCodeSubagentProgress(
|
||||
prompt.requestId, prompt.sessionId
|
||||
)
|
||||
for (const tool of prompt.toolCalls.values()) {
|
||||
if (tool.retainedSubagent) {
|
||||
prompt.subagentProgress.retain({
|
||||
...tool.retainedSubagent,
|
||||
requestId: prompt.requestId, type: 'subagent'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const childEvent = update._meta?.goodbuddySubagentEvent
|
||||
if (childEvent !== undefined) {
|
||||
const event = prompt.subagentProgress.update(
|
||||
childEvent, 'toolCallId' in update ? update.toolCallId : undefined
|
||||
)
|
||||
if (event?.runtimeCallId) {
|
||||
const previous = prompt.toolCalls.get(event.runtimeCallId)
|
||||
prompt.toolCalls.set(event.runtimeCallId, {
|
||||
...previous, retainedSubagent: event
|
||||
})
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
if (
|
||||
(update.sessionUpdate === 'agent_message_chunk' ||
|
||||
update.sessionUpdate === 'agent_thought_chunk') &&
|
||||
@@ -2118,6 +2149,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
update.rawOutput,
|
||||
retainedMaximum
|
||||
),
|
||||
contentOutput:
|
||||
update.content === undefined || update.content === null
|
||||
? previous?.contentOutput
|
||||
: update.content.flatMap((entry) =>
|
||||
entry.type === 'content' && entry.content.type === 'text'
|
||||
? [entry.content.text] : []
|
||||
).join('\n').slice(0, retainedMaximum),
|
||||
retainedInput:
|
||||
update.rawInput === undefined
|
||||
? previous?.retainedInput
|
||||
@@ -2148,22 +2186,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
callId,
|
||||
state: toolCall.status ?? 'pending',
|
||||
input: toolCall.rawInput,
|
||||
output: toolCall.rawOutput
|
||||
output: toolCall.contentOutput || toolCall.rawOutput
|
||||
})
|
||||
: undefined
|
||||
if (subagent) {
|
||||
return subagent
|
||||
return prompt.subagentProgress?.retain(subagent) ?? subagent
|
||||
}
|
||||
if (toolCall.retainedSubagent) {
|
||||
const retained = toolCall.retainedSubagent
|
||||
const state =
|
||||
toolCall.status === 'pending'
|
||||
? ('queued' as const)
|
||||
? retained.state
|
||||
: toolCall.status === 'in_progress'
|
||||
? ('running' as const)
|
||||
: toolCall.status ?? retained.state
|
||||
const detail = safeStringify(
|
||||
toolCall.rawOutput,
|
||||
toolCall.contentOutput || toolCall.rawOutput,
|
||||
retainedMaximum
|
||||
)
|
||||
return {
|
||||
@@ -2191,7 +2229,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
retainedMaximum
|
||||
) ?? toolCall.retainedInput
|
||||
const output = safeStringify(
|
||||
toolCall.rawOutput,
|
||||
toolCall.contentOutput || toolCall.rawOutput,
|
||||
retainedMaximum
|
||||
) ?? toolCall.retainedOutput
|
||||
return {
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
requestProcessTreeTermination,
|
||||
waitForProcessExit
|
||||
} from './child-process-termination'
|
||||
import { toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
import { promptWithUntrustedConversationHistory } from './runtime-conversation-history'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
@@ -2217,6 +2217,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
>()
|
||||
const reasoningPartIds = new Set<string>()
|
||||
const subagentProgress = new OpenCodeSubagentProgress(request.requestId, sessionId)
|
||||
const reportedQuestionIds = new Map<string, string>()
|
||||
let aggregateOutputBytes = 0
|
||||
let outputTruncated = false
|
||||
@@ -2315,6 +2316,18 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const repliedPermissionIds = new Set<string>()
|
||||
const reportedMessageIds = new Set<string>()
|
||||
for await (const event of subscription.stream) {
|
||||
const childProgress = subagentProgress.update(event)
|
||||
if (
|
||||
childProgress &&
|
||||
'sessionID' in event.properties &&
|
||||
event.properties.sessionID !== sessionId
|
||||
) {
|
||||
const task = childProgress.runtimeCallId
|
||||
? toolStates.get(childProgress.runtimeCallId)
|
||||
: undefined
|
||||
if (task) task.subagent = childProgress
|
||||
yield childProgress
|
||||
}
|
||||
if (
|
||||
event.type === 'message.updated' &&
|
||||
event.properties.sessionID === sessionId &&
|
||||
@@ -2424,7 +2437,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
...(subagent ? { subagent } : {})
|
||||
})
|
||||
if (subagent) {
|
||||
yield subagent
|
||||
const retained = subagentProgress.retain(subagent)
|
||||
toolStates.get(callId)!.subagent = retained
|
||||
yield retained
|
||||
} else {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -1,9 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
|
||||
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
||||
|
||||
describe('OpenCode native subagent events', () => {
|
||||
it('extracts ACP output objects without displaying protocol metadata', () => {
|
||||
const event = toOpenCodeSubagentEvent({
|
||||
requestId, callId: 'task', state: 'completed',
|
||||
input: { subagent_type: 'general', prompt: 'Inspect seed' },
|
||||
output: {
|
||||
output: '<task id="child" state="completed">\n<task_result>\n**Done**\n</task_result>\n</task>',
|
||||
metadata: { sessionId: 'child' }
|
||||
}
|
||||
})
|
||||
expect(event?.output).toBe('**Done**')
|
||||
})
|
||||
|
||||
it('keeps ordered child text, reasoning and tools separate from the final result', () => {
|
||||
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
|
||||
const task = (status: string) => ({
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'parent',
|
||||
part: {
|
||||
id: 'task-part', callID: 'task', type: 'tool', tool: 'task',
|
||||
state: {
|
||||
status, input: { subagent_type: 'general', prompt: 'Inspect seed' },
|
||||
metadata: { sessionId: 'child' },
|
||||
...(status === 'completed' ? { output: 'Final result' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
tracker.update(task('running'))
|
||||
tracker.update({
|
||||
type: 'message.updated',
|
||||
properties: { sessionID: 'child', info: { id: 'user', role: 'user' } }
|
||||
})
|
||||
expect(tracker.update({
|
||||
type: 'message.part.updated',
|
||||
properties: { sessionID: 'child', part: {
|
||||
id: 'prompt', messageID: 'user', type: 'text', text: 'Do not display prompt'
|
||||
} }
|
||||
})).toBeUndefined()
|
||||
const delta = (sessionID: string, partID: string, text: string) => ({
|
||||
type: 'message.part.delta',
|
||||
properties: { sessionID, partID, field: 'text', delta: text }
|
||||
})
|
||||
expect(tracker.update(delta('unrelated', 'text', 'Not ours'))).toBeUndefined()
|
||||
tracker.update(delta('child', 'text', 'Inspecting '))
|
||||
const first = tracker.update(delta('child', 'text', 'seed'))
|
||||
expect(first?.progress?.[0]).toMatchObject({ type: 'text', content: 'Inspecting seed' })
|
||||
tracker.update({
|
||||
type: 'message.part.updated', properties: { sessionID: 'child', part: {
|
||||
id: 'thought', type: 'reasoning', text: ''
|
||||
} }
|
||||
})
|
||||
tracker.update(delta('child', 'thought', 'Check contents'))
|
||||
for (const status of ['running', 'completed']) {
|
||||
tracker.update({
|
||||
type: 'message.part.updated', properties: { sessionID: 'child', part: {
|
||||
id: 'read', callID: 'read', type: 'tool', tool: 'read',
|
||||
state: { status, input: { path: 'seed.txt' }, output: 'seed contents' }
|
||||
} }
|
||||
})
|
||||
}
|
||||
const done = tracker.update(task('completed'))
|
||||
expect(done?.output).toBe('Final result')
|
||||
expect(done?.progress?.map(b => b.type)).toEqual(['text', 'reasoning', 'tool'])
|
||||
expect(done?.progress?.[2]).toMatchObject({ tool: { state: 'completed', output: 'seed contents' } })
|
||||
expect(first?.progress).toHaveLength(1)
|
||||
})
|
||||
it('maps Task tool updates to stable bounded subagent identities', () => {
|
||||
const running = toOpenCodeSubagentEvent({
|
||||
requestId,
|
||||
@@ -57,4 +124,23 @@ describe('OpenCode native subagent events', () => {
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes recovered live child events by the ACP owning Task without new metadata', () => {
|
||||
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
|
||||
tracker.retain({
|
||||
...toOpenCodeSubagentEvent({
|
||||
requestId, callId: 'task', state: 'running',
|
||||
input: { subagent_type: 'general', prompt: 'Read seed' }
|
||||
})!,
|
||||
progress: []
|
||||
})
|
||||
const event = {
|
||||
type: 'message.part.delta',
|
||||
properties: { sessionID: 'child', partID: 'text', field: 'text', delta: 'Resumed progress' }
|
||||
}
|
||||
expect(tracker.update(event)).toBeUndefined()
|
||||
expect(tracker.update(event, 'task')).toMatchObject({
|
||||
state: 'running', progress: [{ type: 'text', content: 'Resumed progress' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import { boundedToolDetail } from './approval-summary'
|
||||
|
||||
type OpenCodeSubagentState =
|
||||
@@ -113,11 +114,19 @@ export function toOpenCodeSubagentEvent(
|
||||
? 'completed'
|
||||
: 'failed'
|
||||
const failed = state === 'failed'
|
||||
const detail = isRecord(update.output)
|
||||
? update.output.output ?? update.output.error
|
||||
: update.output
|
||||
const result = typeof detail === 'string'
|
||||
? detail.match(
|
||||
/^\s*<task\b[^>]*>\s*<task_result>\s*([\s\S]*?)\s*<\/task_result>\s*<\/task>\s*$/u
|
||||
)?.[1] ?? detail
|
||||
: detail
|
||||
const output = failed
|
||||
? undefined
|
||||
: boundedDetail(update.output, 16_000)
|
||||
: boundedDetail(result, 16_000)
|
||||
const error = failed
|
||||
? boundedDetail(update.error ?? update.output, 1_000)
|
||||
? boundedDetail(update.error ?? detail, 1_000)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
@@ -138,3 +147,137 @@ export function toOpenCodeSubagentEvent(
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks only sessions explicitly linked by a Task's metadata.sessionId.
|
||||
* The same snapshots travel through the local SDK and the remote ACP bridge.
|
||||
*/
|
||||
export class OpenCodeSubagentProgress {
|
||||
private readonly roles = new Map<string, string>()
|
||||
private readonly reasoning = new Set<string>()
|
||||
private readonly tasks = new Map<string, {
|
||||
event: SubagentEvent
|
||||
sessionId?: string
|
||||
blocks: ConversationMessageBlock[]
|
||||
}>()
|
||||
|
||||
constructor(
|
||||
private readonly requestId: string,
|
||||
private readonly parentSessionId?: string
|
||||
) {}
|
||||
|
||||
update(value: unknown, ownerCallId?: string): SubagentEvent | undefined {
|
||||
if (!isRecord(value) || !isRecord(value.properties)) return undefined
|
||||
const properties = value.properties
|
||||
const part = properties.part
|
||||
if (
|
||||
value.type === 'message.part.updated' &&
|
||||
isRecord(part) && part.type === 'tool' &&
|
||||
part.tool === 'task' && isRecord(part.state) &&
|
||||
(!this.parentSessionId || properties.sessionID === this.parentSessionId) &&
|
||||
![...this.tasks.values()].some(
|
||||
(task) => task.sessionId === properties.sessionID
|
||||
)
|
||||
) {
|
||||
const callId = boundedText(part.callID ?? part.id, 256)
|
||||
if (!callId) return undefined
|
||||
const event = toOpenCodeSubagentEvent({
|
||||
requestId: this.requestId,
|
||||
callId,
|
||||
input: part.state.input,
|
||||
state: part.state.status as OpenCodeSubagentState,
|
||||
output: part.state.output,
|
||||
error: part.state.error
|
||||
})
|
||||
if (!event) return undefined
|
||||
const previous = this.tasks.get(callId)
|
||||
const metadata = part.state.metadata
|
||||
const task = {
|
||||
event,
|
||||
sessionId: (isRecord(metadata)
|
||||
? boundedText(metadata.sessionId, 256)
|
||||
: undefined) ?? previous?.sessionId,
|
||||
blocks: previous?.blocks ?? []
|
||||
}
|
||||
this.tasks.set(callId, task)
|
||||
return { ...event, progress: [...task.blocks] }
|
||||
}
|
||||
const task = [...this.tasks.values()].find(
|
||||
(candidate) => candidate.sessionId === properties.sessionID
|
||||
) ?? (ownerCallId ? this.tasks.get(ownerCallId) : undefined)
|
||||
if (!task) return undefined
|
||||
if (value.type === 'message.updated' && isRecord(properties.info)) {
|
||||
this.roles.set(String(properties.info.id), String(properties.info.role))
|
||||
return undefined
|
||||
}
|
||||
const messageId = isRecord(part) ? part.messageID : properties.messageID
|
||||
if (this.roles.get(String(messageId)) === 'user') return undefined
|
||||
const partId = isRecord(part) ? part.id : properties.partID
|
||||
if (typeof partId !== 'string') return undefined
|
||||
const id = deterministicUuid(`${task.event.childTaskId}\0${partId}`)
|
||||
const index = task.blocks.findIndex((block) => block.id === id)
|
||||
const previous = task.blocks[index]
|
||||
let block: ConversationMessageBlock | undefined
|
||||
if (value.type === 'message.part.updated' && isRecord(part)) {
|
||||
if (part.type === 'reasoning') this.reasoning.add(id)
|
||||
if (
|
||||
(part.type === 'text' || part.type === 'reasoning') &&
|
||||
typeof part.text === 'string' && part.text &&
|
||||
part.ignored !== true && part.synthetic !== true
|
||||
) {
|
||||
block = { id, type: part.type, content: part.text.slice(0, 16_000) }
|
||||
} else if (part.type === 'tool' && isRecord(part.state)) {
|
||||
block = {
|
||||
id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: String(part.callID ?? partId).slice(0, 256),
|
||||
name: String(part.tool).slice(0, 200),
|
||||
summary: boundedText(part.state.title, 240) ?? String(part.tool),
|
||||
state: part.state.status === 'error'
|
||||
? 'failed'
|
||||
: part.state.status as 'pending' | 'running' | 'completed',
|
||||
input: boundedDetail(part.state.input, 4_000),
|
||||
output: boundedDetail(part.state.output, 16_000),
|
||||
error: boundedDetail(part.state.error, 1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
value.type === 'message.part.delta' &&
|
||||
typeof properties.delta === 'string' && properties.delta &&
|
||||
(properties.field === 'text' || properties.field === 'reasoning')
|
||||
) {
|
||||
block = {
|
||||
id,
|
||||
type: this.reasoning.has(id) || previous?.type === 'reasoning' || properties.field === 'reasoning'
|
||||
? 'reasoning'
|
||||
: 'text',
|
||||
content: (
|
||||
(previous?.type === 'text' || previous?.type === 'reasoning'
|
||||
? previous.content : '') + properties.delta
|
||||
).slice(0, 16_000)
|
||||
}
|
||||
}
|
||||
if (!block) return undefined
|
||||
if (index >= 0) task.blocks[index] = block
|
||||
else task.blocks.push(block)
|
||||
return { ...task.event, progress: [...task.blocks] }
|
||||
}
|
||||
|
||||
retain(event: SubagentEvent): SubagentEvent {
|
||||
const task = event.runtimeCallId
|
||||
? this.tasks.get(event.runtimeCallId)
|
||||
: undefined
|
||||
if (!task) {
|
||||
if (event.runtimeCallId && event.progress) {
|
||||
this.tasks.set(event.runtimeCallId, {
|
||||
event, blocks: [...event.progress]
|
||||
})
|
||||
}
|
||||
return event
|
||||
}
|
||||
task.event = event
|
||||
return { ...event, progress: [...task.blocks] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1394,6 +1394,7 @@ function reduceRecoveredAgentEvent(
|
||||
workMode: event.workMode,
|
||||
state: event.state,
|
||||
reason: event.reason,
|
||||
progress: event.progress,
|
||||
output: event.output,
|
||||
error: event.error
|
||||
}
|
||||
|
||||
@@ -23,6 +23,47 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('getWorkspaceChanges', () => {
|
||||
it('reads unborn repositories, nested untracked files, index and worktree diffs separately', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await execute('git', ['init'], { cwd: directory })
|
||||
expect(await getWorkspaceChanges(directory)).toMatchObject({ available: true, files: [], patch: '' })
|
||||
await mkdir(join(directory, 'new directory'))
|
||||
const path = 'new directory/[file].txt'
|
||||
await writeFile(join(directory, path), 'index\n')
|
||||
expect((await getWorkspaceChanges(directory)).files).toContainEqual({ path, status: '??' })
|
||||
expect((await getWorkspaceChanges(directory, path)).patch).toContain('+index')
|
||||
await execute('git', ['add', '--', path], { cwd: directory })
|
||||
await writeFile(join(directory, path), 'worktree\n')
|
||||
const diff = await getWorkspaceChanges(directory, path)
|
||||
expect(diff).toMatchObject({ available: true })
|
||||
expect(diff.stagedPatch).toContain('+index')
|
||||
expect(diff.patch).toContain('-index')
|
||||
expect(diff.patch).toContain('+worktree')
|
||||
})
|
||||
|
||||
it('reads deleted and renamed paths literally without including unrelated changes', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const git = (...args: string[]) => execute('git', args, { cwd: directory })
|
||||
await git('init')
|
||||
await writeFile(join(directory, 'old name.txt'), 'original\n')
|
||||
await writeFile(join(directory, 'other.txt'), 'other\n')
|
||||
await git('add', '.')
|
||||
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
|
||||
await git('mv', 'old name.txt', '[new].txt')
|
||||
await writeFile(join(directory, '[new].txt'), 'edited\n')
|
||||
await writeFile(join(directory, 'other.txt'), 'unrelated\n')
|
||||
const renamed = await getWorkspaceChanges(directory, '[new].txt')
|
||||
expect(renamed.stagedPatch).toContain('rename from old name.txt')
|
||||
expect(renamed.stagedPatch).toContain('rename to [new].txt')
|
||||
expect(renamed.patch).toContain('+edited')
|
||||
expect(renamed.patch).not.toContain('unrelated')
|
||||
await rm(join(directory, '[new].txt'))
|
||||
expect((await getWorkspaceChanges(directory, '[new].txt')).patch).toContain('-original')
|
||||
await git('add', '-u')
|
||||
expect((await getWorkspaceChanges(directory, 'old name.txt')).stagedPatch).toContain('-original')
|
||||
})
|
||||
it('returns tracked and untracked Git workspace changes', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -59,8 +59,8 @@ const previewFileNames = new Set([
|
||||
export class WorkspaceChangesService {
|
||||
constructor(private readonly workspace: WorkspaceAccess) {}
|
||||
|
||||
getChanges(signal?: AbortSignal): Promise<WorkspaceChanges> {
|
||||
return this.workspace.getChanges({ signal })
|
||||
getChanges(signal?: AbortSignal, path?: string): Promise<WorkspaceChanges> {
|
||||
return this.workspace.getChanges({ signal, path })
|
||||
}
|
||||
|
||||
async listDirectory(
|
||||
@@ -158,13 +158,14 @@ export async function resolveWorkspaceEntryPath(
|
||||
}
|
||||
|
||||
export async function getWorkspaceChanges(
|
||||
workspace: string | WorkspaceAccess
|
||||
workspace: string | WorkspaceAccess,
|
||||
path?: string
|
||||
): Promise<WorkspaceChanges> {
|
||||
if (typeof workspace !== 'string') {
|
||||
return new WorkspaceChangesService(workspace).getChanges()
|
||||
return new WorkspaceChangesService(workspace).getChanges(undefined, path)
|
||||
}
|
||||
return withLocalWorkspace(workspace, (access) =>
|
||||
new WorkspaceChangesService(access).getChanges()
|
||||
new WorkspaceChangesService(access).getChanges(undefined, path)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4157,6 +4157,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
|
||||
for (const [channel, input] of [
|
||||
[ipcChannels.workspaceChangesGet, projectId],
|
||||
[ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }],
|
||||
[
|
||||
ipcChannels.workspaceDirectoryList,
|
||||
{ projectId, path: '' }
|
||||
|
||||
+18
-1
@@ -6601,6 +6601,23 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.workspaceFileDiff,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = workspaceFileRequestSchema.omit({ offsetBytes: true }).parse(input)
|
||||
const project = assistantDatabase.getProject(value.projectId)
|
||||
if (project.executionSpace?.kind === 'ssh') {
|
||||
await requireRemoteProjectsEnabled()
|
||||
}
|
||||
const executionSpace = spaceResolver.resolveProject(project)
|
||||
try {
|
||||
return await getWorkspaceChanges(executionSpace.workspaceAccess, value.path)
|
||||
} finally {
|
||||
await executionSpace.workspaceAccess.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.workspaceDirectoryList,
|
||||
async (event, input: unknown) => {
|
||||
@@ -6652,9 +6669,9 @@ export function registerIpcHandlers(
|
||||
await requireRemoteProjectsEnabled()
|
||||
}
|
||||
const executionSpace = spaceResolver.resolveProject(project)
|
||||
spaceResolver.assertLocal(executionSpace)
|
||||
let targetPath: string
|
||||
try {
|
||||
spaceResolver.assertLocal(executionSpace)
|
||||
targetPath = await resolveWorkspaceEntryPath(
|
||||
executionSpace.rootPath,
|
||||
value.path,
|
||||
|
||||
@@ -775,53 +775,76 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
canonicalRoot,
|
||||
input.signal
|
||||
)
|
||||
const [statusResult, patchResult] = await Promise.all([
|
||||
runGit(
|
||||
const git = (args: string[]): Promise<CommandResult> =>
|
||||
runGit(canonicalRoot, withSafeGitArguments(args), input.signal, filterOverrides)
|
||||
const statusResult = await runGit(
|
||||
canonicalRoot,
|
||||
withSafeGitArguments([
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'-z',
|
||||
'--untracked-files=normal'
|
||||
]),
|
||||
input.signal,
|
||||
filterOverrides
|
||||
),
|
||||
runGit(
|
||||
canonicalRoot,
|
||||
withSafeGitArguments([
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-color',
|
||||
'HEAD'
|
||||
'--untracked-files=all'
|
||||
]),
|
||||
input.signal,
|
||||
filterOverrides
|
||||
)
|
||||
])
|
||||
if (statusResult.code !== 0 || patchResult.code !== 0) {
|
||||
const detail = statusResult.stderr || patchResult.stderr
|
||||
if (statusResult.code !== 0) {
|
||||
const detail = statusResult.stderr
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: statusResult.truncated || patchResult.truncated,
|
||||
truncated: statusResult.truncated,
|
||||
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
|
||||
}
|
||||
}
|
||||
const changedFiles = parseChangedFiles(statusResult.stdout)
|
||||
const diffArgs = ['diff', '--no-ext-diff', '--no-textconv', '--no-color']
|
||||
let patchResult: CommandResult
|
||||
let stagedResult: CommandResult | undefined
|
||||
if (input.path !== undefined) {
|
||||
const path = normalizeRelativePath(input.path, false).path
|
||||
const file = changedFiles.files.find((entry) => entry.path === path)
|
||||
if (!file) {
|
||||
throw new Error('File is no longer in the Git changes list')
|
||||
}
|
||||
const paths = [...new Set([file.previousPath, path].filter((value): value is string => value !== undefined))]
|
||||
.map((value) => `:(literal)${value}`)
|
||||
if (file.status === '??') {
|
||||
await this.resolveEntryPath(path, 'file')
|
||||
patchResult = await git([...diffArgs, '--no-index', '--', NULL_DEVICE, path])
|
||||
if (patchResult.code !== 0 && patchResult.code !== 1) {
|
||||
throw new Error(patchResult.stderr || 'Git diff failed')
|
||||
}
|
||||
} else {
|
||||
;[stagedResult, patchResult] = await Promise.all([
|
||||
git([...diffArgs, '--cached', '--', ...paths]),
|
||||
git([...diffArgs, '--', ...paths])
|
||||
])
|
||||
if (stagedResult.code !== 0 || patchResult.code !== 0) {
|
||||
throw new Error(stagedResult.stderr || patchResult.stderr || 'Git diff failed')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const head = await git(['rev-parse', '--verify', '--quiet', 'HEAD'])
|
||||
patchResult = await git([...diffArgs, ...(head.code === 0 ? ['HEAD'] : ['--cached'])])
|
||||
}
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: true,
|
||||
status: formatChangedFiles(changedFiles.files),
|
||||
patch: patchResult.stdout,
|
||||
...(patchResult.code !== 0 && input.path === undefined
|
||||
? { error: patchResult.stderr.trim().slice(0, 2000) || 'Git diff failed' }
|
||||
: {}),
|
||||
...(stagedResult ? { stagedPatch: stagedResult.stdout } : {}),
|
||||
files: changedFiles.files,
|
||||
truncated:
|
||||
statusResult.truncated ||
|
||||
patchResult.truncated ||
|
||||
stagedResult?.truncated === true ||
|
||||
changedFiles.truncated
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -113,6 +113,26 @@ function createAccess(lease: RemoteWorkspaceTransportLease): {
|
||||
}
|
||||
|
||||
describe('RemoteWorkspaceAccess', () => {
|
||||
it('requests both single-file diff layers and preserves raw Git status', async () => {
|
||||
const lease = createLease({
|
||||
getGitStatus: vi.fn(async () => ({
|
||||
repositoryIdentity: 'repository-1', branch: 'main', truncated: false,
|
||||
entries: [{ relativePath: '[file].txt', index: 'modified' as const, worktree: 'unmodified' as const, statusCode: 'T.' }]
|
||||
})),
|
||||
getGitDiff: vi.fn(async (request) => ({
|
||||
repositoryIdentity: 'repository-1', patch: request.staged ? 'staged' : 'worktree', byteLength: request.staged ? 6 : 8, truncated: request.staged
|
||||
}))
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
expect(await access.getChanges({ path: '[file].txt' })).toMatchObject({
|
||||
available: true, patch: 'worktree', stagedPatch: 'staged', truncated: true,
|
||||
files: [{ path: '[file].txt', status: 'T ' }]
|
||||
})
|
||||
expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: true }), undefined)
|
||||
expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: false }), undefined)
|
||||
expect(lease.statWorkspace).not.toHaveBeenCalled()
|
||||
await access.dispose()
|
||||
})
|
||||
it('lazily validates once and maps bounded workspace methods', async () => {
|
||||
const lease = createLease({
|
||||
listWorkspace: vi.fn(async () => ({
|
||||
|
||||
@@ -247,7 +247,7 @@ function mapChangedFiles(
|
||||
: `${gitStatusCharacter(entry.index)}${gitStatusCharacter(entry.worktree)}`
|
||||
return {
|
||||
path: entry.relativePath,
|
||||
status,
|
||||
status: entry.statusCode?.replaceAll('.', ' ') ?? status,
|
||||
...(entry.originalRelativePath
|
||||
? { previousPath: entry.originalRelativePath }
|
||||
: {})
|
||||
@@ -577,8 +577,7 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
input.signal?.throwIfAborted()
|
||||
const reference = this.reference(opened)
|
||||
const [status, diff] = await Promise.all([
|
||||
opened.lease
|
||||
const status = await opened.lease
|
||||
.getGitStatus(
|
||||
remoteGitStatusRequestSchema.parse({
|
||||
...reference,
|
||||
@@ -590,12 +589,13 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
)
|
||||
.then((result) =>
|
||||
remoteGitStatusResultSchema.parse(result)
|
||||
),
|
||||
opened.lease
|
||||
)
|
||||
const readDiff = (staged: boolean) => opened.lease
|
||||
.getGitDiff(
|
||||
remoteGitDiffRequestSchema.parse({
|
||||
...reference,
|
||||
staged: false,
|
||||
staged,
|
||||
...(input.path === undefined ? {} : { relativePath: input.path }),
|
||||
maximumBytes:
|
||||
REMOTE_WORKSPACE_LIMITS.maximumGitDiffBytes
|
||||
}),
|
||||
@@ -604,8 +604,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
.then((result) =>
|
||||
remoteGitDiffResultSchema.parse(result)
|
||||
)
|
||||
const [diff, stagedDiff] = await Promise.all([
|
||||
readDiff(false),
|
||||
input.path === undefined ? undefined : readDiff(true)
|
||||
])
|
||||
if (status.repositoryIdentity !== diff.repositoryIdentity) {
|
||||
if (status.repositoryIdentity !== diff.repositoryIdentity ||
|
||||
(stagedDiff && status.repositoryIdentity !== stagedDiff.repositoryIdentity)) {
|
||||
if (attempt === 0) {
|
||||
continue
|
||||
}
|
||||
@@ -617,10 +621,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
available: true,
|
||||
status: formatChangedFiles(files),
|
||||
patch: diff.patch,
|
||||
...(stagedDiff ? { stagedPatch: stagedDiff.patch } : {}),
|
||||
files,
|
||||
truncated:
|
||||
status.truncated ||
|
||||
diff.truncated ||
|
||||
stagedDiff?.truncated === true ||
|
||||
diff.nextCursor !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export type SearchPage = {
|
||||
}
|
||||
|
||||
export type WorkspaceChangesInput = {
|
||||
path?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
|
||||
@@ -1089,6 +1089,11 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
getFileDiff: (projectId: string, path: string) =>
|
||||
ipcRenderer.invoke(ipcChannels.workspaceFileDiff, {
|
||||
projectId,
|
||||
path
|
||||
}) as Promise<WorkspaceChanges>,
|
||||
getChanges: (projectId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.workspaceChangesGet,
|
||||
|
||||
@@ -523,6 +523,7 @@ const api: DesktopApi = {
|
||||
})
|
||||
},
|
||||
workspace: {
|
||||
getFileDiff: vi.fn(),
|
||||
getChanges: vi.fn(async () => ({
|
||||
rootPath: 'C:\\Workspace',
|
||||
available: true,
|
||||
@@ -4775,7 +4776,14 @@ describe('App', () => {
|
||||
name: /产品知识/u
|
||||
})
|
||||
await waitFor(() => expect(scopeCheckbox).toHaveFocus())
|
||||
fireEvent.click(scopeCheckbox)
|
||||
fireEvent.focusOut(scopeCheckbox, { relatedTarget: null })
|
||||
expect(knowledgeScopeTrigger).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByText('产品知识', { selector: 'span' }))
|
||||
expect(scopeCheckbox).toBeChecked()
|
||||
fireEvent.click(scopeCheckbox.closest('label')!)
|
||||
expect(scopeCheckbox).not.toBeChecked()
|
||||
fireEvent.click(scopeCheckbox.closest('label')!.querySelector('small')!)
|
||||
expect(scopeCheckbox).toBeChecked()
|
||||
const composerInput = screen.getByLabelText('向 GoodBuddy 提问')
|
||||
fireEvent.focusOut(scopeCheckbox, {
|
||||
relatedTarget: composerInput
|
||||
@@ -5815,6 +5823,13 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('refreshes generated workspace files when a run completes', async () => {
|
||||
vi.mocked(api.workspace.listDirectory).mockImplementation(async (_projectId, path) => ({
|
||||
path,
|
||||
entries: vi.mocked(api.workspace.getChanges).mock.calls.length >= 2
|
||||
? [{ name: 'tree-only.txt', path: 'tree-only.txt', type: 'file' as const }]
|
||||
: [],
|
||||
truncated: false
|
||||
}))
|
||||
vi.mocked(api.workspace.getChanges)
|
||||
.mockResolvedValueOnce({
|
||||
rootPath: project.rootPath,
|
||||
@@ -5852,6 +5867,7 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findByText('generated.md')).toBeInTheDocument()
|
||||
expect(await screen.findByText('tree-only.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores stale Git changes after switching projects', async () => {
|
||||
@@ -10246,8 +10262,8 @@ describe('App', () => {
|
||||
screen.getByRole('tab', { name: '工作区' })
|
||||
).toHaveAttribute('title', '浏览项目文件、Git 变更与文件内容')
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '项目工作区' })
|
||||
).toBeInTheDocument()
|
||||
screen.queryByRole('heading', { name: '项目工作区' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '刷新工作区文件' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -4740,6 +4740,7 @@ function App(): React.JSX.Element {
|
||||
workMode: event.workMode,
|
||||
state: event.state,
|
||||
reason: event.reason,
|
||||
progress: event.progress,
|
||||
output: event.output,
|
||||
error: event.error
|
||||
}
|
||||
@@ -5886,6 +5887,10 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
const loadWorkspaceDiff = useCallback(async (path: string) => {
|
||||
if (!activeProjectId) throw new Error(tRef.current('notices.selectProject'))
|
||||
return window.goodbuddy.workspace.getFileDiff(activeProjectId, path)
|
||||
}, [activeProjectId])
|
||||
const openWorkspaceEntry = useCallback(
|
||||
async (
|
||||
path: string,
|
||||
@@ -9787,7 +9792,7 @@ function App(): React.JSX.Element {
|
||||
className="knowledge-scope"
|
||||
onBlurCapture={(event) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setKnowledgeScopeOpen(false)
|
||||
@@ -11228,6 +11233,7 @@ function App(): React.JSX.Element {
|
||||
onListWorkspaceDirectory={listWorkspaceDirectory}
|
||||
onLoadWorkspaceFile={loadWorkspaceFile}
|
||||
onOpenWorkspaceEntry={openWorkspaceEntry}
|
||||
onLoadWorkspaceDiff={loadWorkspaceDiff}
|
||||
onRefreshChanges={refreshWorkspaceChanges}
|
||||
onTabChange={setAssistantSidebarTab}
|
||||
open={assistantSidebarOpen}
|
||||
|
||||
@@ -408,6 +408,46 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders live child progress in order and keeps the final result separate', () => {
|
||||
const child = {
|
||||
childTaskId: '00000000-0000-4000-8000-000000000111',
|
||||
expertId: '00000000-0000-4000-8000-000000000211',
|
||||
expertName: 'general', routingMode: 'native' as const,
|
||||
state: 'running' as const,
|
||||
progress: [
|
||||
{ id: 'text', type: 'text' as const, content: 'Inspecting seed' },
|
||||
{ id: 'tool', type: 'tool' as const, tool: {
|
||||
callId: 'read', name: 'read', state: 'completed' as const,
|
||||
summary: 'Read seed', output: 'seed contents'
|
||||
} },
|
||||
{ id: 'reasoning', type: 'reasoning' as const, content: 'Check the result' }
|
||||
]
|
||||
}
|
||||
const props = {
|
||||
artifactById: new Map(), conversationId: 'conversation-1',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
|
||||
}
|
||||
const message: Message = {
|
||||
id: 'assistant', role: 'assistant', content: '', createdAt: 0,
|
||||
state: 'streaming', subagents: [child]
|
||||
}
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.queryByText('Inspecting seed')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('general', { selector: 'strong' }))
|
||||
fireEvent(screen.getByText('general', { selector: 'strong' }).closest('details')!, new Event('toggle'))
|
||||
const progress = screen.getByRole('region', { name: '执行过程' })
|
||||
expect(within(progress).getByText('Inspecting seed')).toBeInTheDocument()
|
||||
expect(within(progress).getByText('Check the result').closest('details')).not.toHaveAttribute('open')
|
||||
expect(screen.queryByRole('region', { name: '最终结果' })).not.toBeInTheDocument()
|
||||
view.rerender(<ChatTimeline {...props} messages={[{
|
||||
...message, state: 'complete',
|
||||
subagents: [{ ...child, state: 'completed', output: 'Final seed' }]
|
||||
}]} />)
|
||||
expect(screen.getByRole('region', { name: '最终结果' })).toHaveTextContent('Final seed')
|
||||
expect(screen.getByRole('region', { name: '执行过程' })).not.toHaveTextContent('Final seed')
|
||||
})
|
||||
|
||||
it('labels native OpenCode subagents and shows their task description', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
|
||||
@@ -292,6 +292,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
actor.kind === 'direct-model' && subagent.workMode
|
||||
? `${source} · ${subagent.workMode === 'execute' ? 'Execute' : 'Ask'}`
|
||||
: source
|
||||
const progress = subagent.progress?.filter((block, index, blocks) =>
|
||||
!(
|
||||
subagent.state === 'completed' &&
|
||||
index === blocks.length - 1 &&
|
||||
block.type === 'text' &&
|
||||
block.content.trim() === subagent.output?.trim()
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<details
|
||||
@@ -332,9 +340,37 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</summary>
|
||||
{expanded && (
|
||||
<div className="subagent-status-card__details">
|
||||
{progress && progress.length > 0 && (
|
||||
<section aria-label={t('chat.subagents.progress')}>
|
||||
<strong>{t('chat.subagents.progress')}</strong>
|
||||
<div className="subagent-status-card__progress">
|
||||
{groupMessageBlocks(progress).map((item) =>
|
||||
item.kind === 'tools' ? (
|
||||
<ToolExecutionList key={item.id} tools={item.tools.map((tool) =>
|
||||
(tool.state === 'pending' || tool.state === 'running') &&
|
||||
subagent.state !== 'queued' && subagent.state !== 'running'
|
||||
? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' }
|
||||
: tool
|
||||
)} />
|
||||
) : item.kind === 'subagents' ? null
|
||||
: item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
key={item.block.id}
|
||||
content={item.block.content}
|
||||
streaming={false}
|
||||
/>
|
||||
) : item.block.type === 'text' ? (
|
||||
<div key={item.block.id} className="markdown-content">
|
||||
<MarkdownRenderer>{item.block.content}</MarkdownRenderer>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{subagent.output ? (
|
||||
<section>
|
||||
<strong>{t('chat.subagents.output')}</strong>
|
||||
<section aria-label={t('chat.subagents.finalOutput')}>
|
||||
<strong>{t('chat.subagents.finalOutput')}</strong>
|
||||
<div className="markdown-content">
|
||||
<MarkdownRenderer
|
||||
renderHtml={
|
||||
@@ -345,9 +381,9 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
) : !subagent.progress?.length ? (
|
||||
<p>{t('chat.subagents.noOutput')}</p>
|
||||
)}
|
||||
) : null}
|
||||
{subagent.error &&
|
||||
(subagent.state === 'failed' ||
|
||||
subagent.state === 'cancelled') && (
|
||||
|
||||
@@ -76,6 +76,7 @@ function renderSidebar({
|
||||
onListWorkspaceDirectory={onListWorkspaceDirectory}
|
||||
onLoadArtifact={onLoadArtifact}
|
||||
onLoadWorkspaceFile={onLoadWorkspaceFile}
|
||||
onLoadWorkspaceDiff={vi.fn()}
|
||||
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
||||
onInteractBrowser={vi.fn(async () => undefined)}
|
||||
onRefreshChanges={vi.fn(async () => undefined)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderTree,
|
||||
ClockFading,
|
||||
Monitor,
|
||||
Plus,
|
||||
@@ -117,6 +116,7 @@ type RightAssistantSidebarProps = {
|
||||
onImportArtifacts: () => Promise<void>
|
||||
onLoadArtifact: (artifactId: string) => Promise<void>
|
||||
onRefreshChanges: () => Promise<void>
|
||||
onLoadWorkspaceDiff: (path: string) => Promise<WorkspaceChanges>
|
||||
onListWorkspaceDirectory: (
|
||||
path: string
|
||||
) => Promise<WorkspaceDirectoryListing>
|
||||
@@ -500,6 +500,7 @@ export function RightAssistantSidebar({
|
||||
onImportArtifacts,
|
||||
onLoadArtifact,
|
||||
onRefreshChanges,
|
||||
onLoadWorkspaceDiff,
|
||||
onListWorkspaceDirectory,
|
||||
onLoadWorkspaceFile,
|
||||
onOpenWorkspaceEntry,
|
||||
@@ -629,7 +630,6 @@ export function RightAssistantSidebar({
|
||||
useState(false)
|
||||
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
|
||||
useState('')
|
||||
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
|
||||
const [taskFilter, setTaskFilter] = useState<
|
||||
'attention' | 'active' | 'paused' | 'finished'
|
||||
>('active')
|
||||
@@ -1583,15 +1583,12 @@ export function RightAssistantSidebar({
|
||||
</section>
|
||||
) : (
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<FolderTree size={15} />
|
||||
{t('sidebar.workspace.projectTitle')}
|
||||
<div className="workspace-files__toolbar">
|
||||
<button
|
||||
aria-label={t('sidebar.workspace.refreshAriaLabel')}
|
||||
className="icon-button"
|
||||
disabled={!workspaceProjectId}
|
||||
onClick={() => {
|
||||
setWorkspaceRefreshVersion((current) => current + 1)
|
||||
void runAction(
|
||||
onRefreshChanges,
|
||||
t('sidebar.errors.refreshWorkspace')
|
||||
@@ -1602,12 +1599,14 @@ export function RightAssistantSidebar({
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</h3>
|
||||
</div>
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
|
||||
key={`${workspaceProjectId ?? 'none'}:${workspaceRefreshVersion}`}
|
||||
key={workspaceProjectId ?? 'none'}
|
||||
refreshToken={workspaceChanges}
|
||||
onLoadDiff={onLoadWorkspaceDiff}
|
||||
onListDirectory={onListWorkspaceDirectory}
|
||||
onOpenEntry={onOpenWorkspaceEntry}
|
||||
onOpenEntry={currentProject?.executionSpace?.kind === 'ssh' ? undefined : onOpenWorkspaceEntry}
|
||||
onOpenFile={openWorkspaceFile}
|
||||
projectId={workspaceProjectId}
|
||||
/>
|
||||
@@ -1618,17 +1617,6 @@ export function RightAssistantSidebar({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{workspaceChanges?.patch && (
|
||||
<details className="assistant-sidebar__diff-details">
|
||||
<summary>{t('sidebar.workspace.fullDiff')}</summary>
|
||||
<pre className="assistant-sidebar__diff">
|
||||
{workspaceChanges.patch}
|
||||
{workspaceChanges.truncated
|
||||
? t('sidebar.workspace.truncatedDiff')
|
||||
: ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
@@ -9,6 +9,9 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it.each([['??', 'U'], [' M', 'M'], ['A ', 'A'], [' D', 'D'], ['R ', 'R'], ['C ', 'C'], ['UU', 'U'], ['AM', 'AM'], [' T', 'T']])('maps %s to %s without calling every new file added', (status, letter) => {
|
||||
expect(gitStatusLetter(status)).toBe(letter)
|
||||
})
|
||||
it('lists the project tree, expands directories, and opens files', async () => {
|
||||
const onListDirectory = vi.fn(async (path: string) =>
|
||||
path
|
||||
@@ -45,6 +48,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={onOpenEntry}
|
||||
@@ -74,7 +78,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
})
|
||||
)
|
||||
expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory')
|
||||
expect(screen.getAllByText('修改')).not.toHaveLength(0)
|
||||
expect(screen.getAllByText('M')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores stale directory results after the active project changes', async () => {
|
||||
@@ -100,6 +104,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
})
|
||||
const { rerender } = render(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
@@ -111,6 +116,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledOnce())
|
||||
rerender(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
@@ -133,6 +139,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
@@ -149,7 +156,85 @@ describe('WorkspaceFilesPanel', () => {
|
||||
await screen.findByText('Current workspace')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Uncommitted changes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Modified')).toBeInTheDocument()
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
expect(onListDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('refreshes expanded directories and drops collapsed caches without remounting', async () => {
|
||||
let version = 0
|
||||
const onListDirectory = vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: path ? [{ name: `file-${version}.txt`, path: `${path}/file-${version}.txt`, type: 'file' as const }]
|
||||
: [{ name: 'docs', path: 'docs', type: 'directory' as const }],
|
||||
truncated: false
|
||||
}))
|
||||
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
|
||||
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
await screen.findByText('file-0.txt')
|
||||
version += 1
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
await screen.findByText('file-1.txt')
|
||||
expect(screen.queryByText('file-0.txt')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'docs' }))
|
||||
version += 1
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledTimes(5))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'docs' }))
|
||||
await screen.findByText('file-2.txt')
|
||||
expect(screen.queryByRole('button', { name: /默认应用|资源管理器/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a deleted file diff rather than reading the missing file', async () => {
|
||||
const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '-deleted content', stagedPatch: '-staged content', truncated: true }))
|
||||
const onOpenFile = vi.fn()
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[{ path: 'deleted.txt', status: ' D' }]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={onLoadDiff} onOpenFile={onOpenFile} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /deleted.txt/ }))
|
||||
await screen.findByText('-deleted content')
|
||||
expect(screen.getByText('-staged content')).toBeInTheDocument()
|
||||
expect(screen.getByText('差异内容已截断。')).toBeInTheDocument()
|
||||
expect(onLoadDiff).toHaveBeenCalledWith('deleted.txt')
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads the selected diff on refresh and ignores an older pending response', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof onLoadDiff>>) => void
|
||||
const value = { rootPath: '/project', available: true, files: [], status: '', patch: '+fresh', stagedPatch: '', truncated: false }
|
||||
const onLoadDiff = vi.fn<() => Promise<typeof value>>()
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
.mockResolvedValue(value)
|
||||
const props = {
|
||||
projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }],
|
||||
onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })),
|
||||
onLoadDiff, onOpenFile: vi.fn()
|
||||
}
|
||||
const view = render(<WorkspaceFilesPanel {...props} refreshToken={0} />)
|
||||
await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: /seed.txt/ }))
|
||||
view.rerender(<WorkspaceFilesPanel {...props} refreshToken={1} />)
|
||||
await screen.findByText('+fresh')
|
||||
await act(async () => resolveOld({ ...value, patch: '+stale' }))
|
||||
expect(screen.queryByText('+stale')).not.toBeInTheDocument()
|
||||
expect(onLoadDiff).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('loads more changed files so deleted files after the first batch have a diff entry', async () => {
|
||||
const onLoadDiff = vi.fn(async () => ({
|
||||
rootPath: '/project', available: true, files: [], status: '',
|
||||
patch: '-last deleted', stagedPatch: '', truncated: false
|
||||
}))
|
||||
render(<WorkspaceFilesPanel
|
||||
projectId="project"
|
||||
changedFiles={Array.from({ length: 51 }, (_, index) => ({ path: `deleted-${index}.txt`, status: ' D' }))}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={onLoadDiff} onOpenFile={vi.fn()}
|
||||
/>)
|
||||
expect(screen.queryByRole('button', { name: /deleted-50.txt/ })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载更多变更(剩余 1 个)' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /deleted-50.txt/ }))
|
||||
await screen.findByText('-last deleted')
|
||||
expect(onLoadDiff).toHaveBeenCalledWith('deleted-50.txt')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Folder,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryEntry,
|
||||
WorkspaceDirectoryListing
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -23,36 +26,27 @@ import type {
|
||||
type WorkspaceFilesPanelProps = {
|
||||
projectId?: string
|
||||
changedFiles: WorkspaceChangedFile[]
|
||||
refreshToken?: unknown
|
||||
onLoadDiff: (path: string) => Promise<WorkspaceChanges>
|
||||
onListDirectory: (path: string) => Promise<WorkspaceDirectoryListing>
|
||||
onOpenFile: (path: string) => void
|
||||
onOpenEntry: (
|
||||
onOpenEntry?: (
|
||||
path: string,
|
||||
type: WorkspaceDirectoryEntry['type']
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
function statusKey(
|
||||
status: string
|
||||
): 'added' | 'deleted' | 'renamed' | 'modified' {
|
||||
const value = status.trim()
|
||||
if (value === '??') {
|
||||
return 'added'
|
||||
}
|
||||
if (value.includes('D')) {
|
||||
return 'deleted'
|
||||
}
|
||||
if (value.includes('R')) {
|
||||
return 'renamed'
|
||||
}
|
||||
if (value.includes('A')) {
|
||||
return 'added'
|
||||
}
|
||||
return 'modified'
|
||||
export function gitStatusLetter(status: string): string {
|
||||
if (status === '??') return 'U'
|
||||
if (['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(status)) return 'U'
|
||||
return status.trim().replaceAll('.', '').split('').filter((value, index, values) => values.indexOf(value) === index).join('')
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({
|
||||
projectId,
|
||||
changedFiles,
|
||||
refreshToken,
|
||||
onLoadDiff,
|
||||
onListDirectory,
|
||||
onOpenFile,
|
||||
onOpenEntry
|
||||
@@ -80,6 +74,18 @@ export function WorkspaceFilesPanel({
|
||||
}>({})
|
||||
const requestGeneration = useRef(0)
|
||||
const inFlightPaths = useRef(new Set<string>())
|
||||
const [diff, setDiff] = useState<{ path: string; value?: WorkspaceChanges; error?: string }>()
|
||||
const diffRequest = useRef(0)
|
||||
const [visibleChangeCount, setVisibleChangeCount] = useState(50)
|
||||
const openDiff = (path: string): void => {
|
||||
const request = ++diffRequest.current
|
||||
setDiff({ path })
|
||||
void onLoadDiff(path).then((value) => {
|
||||
if (request === diffRequest.current) setDiff({ path, value, error: value.error })
|
||||
}).catch((reason: unknown) => {
|
||||
if (request === diffRequest.current) setDiff({ path, error: reason instanceof Error ? reason.message : t('files.errors.read') })
|
||||
})
|
||||
}
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string, generation: number): Promise<void> => {
|
||||
@@ -118,8 +124,8 @@ export function WorkspaceFilesPanel({
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
inFlightPaths.current.delete(path)
|
||||
if (requestGeneration.current === generation) {
|
||||
inFlightPaths.current.delete(path)
|
||||
setLoadingState((current) => {
|
||||
const next = new Set(
|
||||
current.projectId === projectId ? current.value : []
|
||||
@@ -133,6 +139,14 @@ export function WorkspaceFilesPanel({
|
||||
[onListDirectory, projectId]
|
||||
)
|
||||
|
||||
const refreshDirectories = useEffectEvent((generation: number) => {
|
||||
const paths = expandedState.projectId === projectId ? [...expandedState.value] : []
|
||||
setListingState({ projectId, value: {} })
|
||||
setLoadingState({ projectId, value: new Set() })
|
||||
void loadDirectory('', generation)
|
||||
for (const path of paths) void loadDirectory(path, generation)
|
||||
if (diff) openDiff(diff.path)
|
||||
})
|
||||
useEffect(() => {
|
||||
requestGeneration.current += 1
|
||||
const generation = requestGeneration.current
|
||||
@@ -141,10 +155,14 @@ export function WorkspaceFilesPanel({
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
void loadDirectory('', generation)
|
||||
refreshDirectories(generation)
|
||||
}, 0)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [loadDirectory, projectId])
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
requestGeneration.current += 1
|
||||
diffRequest.current += 1
|
||||
}
|
||||
}, [loadDirectory, projectId, refreshToken])
|
||||
|
||||
const changedByPath = useMemo(
|
||||
() =>
|
||||
@@ -190,7 +208,7 @@ export function WorkspaceFilesPanel({
|
||||
|
||||
const openEntry = (entry: WorkspaceDirectoryEntry): void => {
|
||||
setErrorState({ projectId })
|
||||
void onOpenEntry(entry.path, entry.type).catch((reason: unknown) => {
|
||||
void onOpenEntry?.(entry.path, entry.type).catch((reason: unknown) => {
|
||||
setErrorState({
|
||||
projectId,
|
||||
value:
|
||||
@@ -227,7 +245,7 @@ export function WorkspaceFilesPanel({
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
<button
|
||||
{onOpenEntry && <button
|
||||
aria-label={t('files.openFolderAriaLabel', {
|
||||
name: entry.name
|
||||
})}
|
||||
@@ -237,7 +255,7 @@ export function WorkspaceFilesPanel({
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="workspace-files__children">
|
||||
@@ -271,12 +289,12 @@ export function WorkspaceFilesPanel({
|
||||
<FileText size={15} />
|
||||
<span>{entry.name}</span>
|
||||
{changed && (
|
||||
<small className="workspace-files__change">
|
||||
{t(`files.statuses.${statusKey(changed.status)}`)}
|
||||
<small className="workspace-files__change" data-status={gitStatusLetter(changed.status)} title={changed.status}>
|
||||
{gitStatusLetter(changed.status)}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
{onOpenEntry && <button
|
||||
aria-label={t('files.openFileAriaLabel', {
|
||||
name: entry.name
|
||||
})}
|
||||
@@ -286,7 +304,7 @@ export function WorkspaceFilesPanel({
|
||||
type="button"
|
||||
>
|
||||
<FileSearch size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -300,34 +318,68 @@ export function WorkspaceFilesPanel({
|
||||
}
|
||||
|
||||
const root = listings['']
|
||||
const renderPatch = (patch: string): React.JSX.Element => (
|
||||
<pre className="assistant-sidebar__diff workspace-files__diff">
|
||||
{patch.split('\n').map((line, index) => (
|
||||
<span key={index} data-line={
|
||||
line.startsWith('+++') || line.startsWith('---') ? 'header'
|
||||
: line.startsWith('+') ? 'added'
|
||||
: line.startsWith('-') ? 'deleted'
|
||||
: line.startsWith('@@') ? 'hunk' : undefined
|
||||
}>{line}{'\n'}</span>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
if (diff) {
|
||||
return <section className="assistant-sidebar__preview" aria-busy={!diff.value && !diff.error}>
|
||||
<header>
|
||||
<button className="assistant-sidebar__back" type="button" onClick={() => {
|
||||
diffRequest.current += 1
|
||||
setDiff(undefined)
|
||||
}}><ChevronLeft size={14} />{t('files.currentWorkspace')}</button>
|
||||
<strong>{diff.path}</strong>
|
||||
</header>
|
||||
{diff.error ? <p role="alert">{diff.error}<button type="button" onClick={() => openDiff(diff.path)}>{t('files.retry')}</button></p>
|
||||
: !diff.value ? <p>{t('files.reading')}</p> : <>
|
||||
{diff.value.stagedPatch && <><h4>{t('files.staged')}</h4>{renderPatch(diff.value.stagedPatch)}</>}
|
||||
{diff.value.patch && <><h4>{t('files.unstaged')}</h4>{renderPatch(diff.value.patch)}</>}
|
||||
{!diff.value.patch && !diff.value.stagedPatch && <p>{t('files.noDiff')}</p>}
|
||||
{diff.value.truncated && <p>{t('files.diffTruncated')}</p>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
return (
|
||||
<div className="workspace-files">
|
||||
{changedFiles.length > 0 && (
|
||||
<div className="workspace-files__changed">
|
||||
<strong>{t('files.changedTitle')}</strong>
|
||||
{changedFiles.slice(0, 50).map((file) => {
|
||||
const deleted = file.status.includes('D')
|
||||
{changedFiles.slice(0, visibleChangeCount).map((file) => {
|
||||
return (
|
||||
<button
|
||||
className="workspace-files__changed-row"
|
||||
disabled={deleted}
|
||||
key={`${file.status}:${file.path}`}
|
||||
onClick={() => onOpenFile(file.path)}
|
||||
title={file.path}
|
||||
onClick={() => openDiff(file.path)}
|
||||
title={`${file.previousPath ? `${file.previousPath} -> ` : ''}${file.path} (${file.status})`}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{file.path}</span>
|
||||
<small>
|
||||
{t(`files.statuses.${statusKey(file.status)}`)}
|
||||
<small data-status={gitStatusLetter(file.status)} data-conflict={['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(file.status)} aria-label={file.status}>
|
||||
{gitStatusLetter(file.status)}
|
||||
</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{changedFiles.length > 50 && (
|
||||
<p className="workspace-files__status">
|
||||
{t('files.changesTruncated')}
|
||||
</p>
|
||||
{changedFiles.length > visibleChangeCount && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => setVisibleChangeCount((count) => count + 50)}
|
||||
>
|
||||
{t('files.loadMoreChanges', {
|
||||
count: changedFiles.length - visibleChangeCount
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -376,6 +376,8 @@ export const app = {
|
||||
fallbackTask: '{{name}} subagent task',
|
||||
task: 'Task',
|
||||
output: 'Subagent output',
|
||||
progress: 'Execution progress',
|
||||
finalOutput: 'Final result',
|
||||
error: 'Execution details',
|
||||
noOutput: 'This subagent has no output to display yet.',
|
||||
incomplete:
|
||||
|
||||
@@ -432,6 +432,11 @@ export const workspace = {
|
||||
taskList: 'Task list'
|
||||
},
|
||||
files: {
|
||||
staged: 'Staged changes',
|
||||
unstaged: 'Working tree changes',
|
||||
noDiff: 'No textual differences (the file may be empty or only its status changed).',
|
||||
diffTruncated: 'Diff output truncated.',
|
||||
retry: 'Retry',
|
||||
statuses: {
|
||||
added: 'Added',
|
||||
deleted: 'Deleted',
|
||||
@@ -452,7 +457,7 @@ export const workspace = {
|
||||
'This folder has more than 500 items. Only the first 500 are shown.',
|
||||
selectProject: 'Select a project to browse its workspace.',
|
||||
changedTitle: 'Uncommitted changes',
|
||||
changesTruncated: 'Only the first 50 uncommitted changes are shown.',
|
||||
loadMoreChanges: 'Load more changes ({{count}} remaining)',
|
||||
currentWorkspace: 'Current workspace',
|
||||
readingWorkspace: 'Reading workspace…',
|
||||
rootTruncated:
|
||||
|
||||
@@ -358,6 +358,8 @@ export const app = {
|
||||
fallbackTask: '{{name}} 子专家任务',
|
||||
task: '任务',
|
||||
output: '子代理输出',
|
||||
progress: '执行过程',
|
||||
finalOutput: '最终结果',
|
||||
error: '执行说明',
|
||||
noOutput: '子代理暂时没有可显示的输出。',
|
||||
incomplete: '父请求已结束,但子 Agent 未报告完成状态。',
|
||||
|
||||
@@ -422,6 +422,11 @@ export const workspace = {
|
||||
taskList: '任务列表'
|
||||
},
|
||||
files: {
|
||||
staged: '已暂存更改',
|
||||
unstaged: '工作树更改',
|
||||
noDiff: '没有文本差异(文件可能为空,或仅状态发生变化)。',
|
||||
diffTruncated: '差异内容已截断。',
|
||||
retry: '重试',
|
||||
statuses: {
|
||||
added: '新增',
|
||||
deleted: '删除',
|
||||
@@ -441,7 +446,7 @@ export const workspace = {
|
||||
directoryTruncated: '目录项目超过 500 项,仅显示前 500 项。',
|
||||
selectProject: '选择项目后可浏览项目工作区。',
|
||||
changedTitle: '未提交更改',
|
||||
changesTruncated: '仅显示前 50 个未提交更改。',
|
||||
loadMoreChanges: '加载更多变更(剩余 {{count}} 个)',
|
||||
currentWorkspace: '当前工作区',
|
||||
readingWorkspace: '正在读取工作区…',
|
||||
rootTruncated: '根目录项目超过 500 项,仅显示前 500 项。',
|
||||
|
||||
+58
-10
@@ -4283,16 +4283,6 @@ button > svg {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-sidebar__diff-details {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.assistant-sidebar__diff-details summary {
|
||||
padding: var(--space-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-files {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -4300,6 +4290,46 @@ button > svg {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-files__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="added"] {
|
||||
color: var(--success);
|
||||
background: var(--success-subtle);
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="deleted"] {
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="hunk"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files [data-status] {
|
||||
color: var(--warning);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-files [data-status="A"],
|
||||
.workspace-files [data-status="U"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.workspace-files [data-status="D"],
|
||||
.workspace-files [data-conflict="true"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workspace-files [data-status="R"],
|
||||
.workspace-files [data-status="C"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files__heading,
|
||||
.workspace-files__changed > strong {
|
||||
color: var(--text-secondary);
|
||||
@@ -6226,6 +6256,14 @@ button > svg {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.subagent-status-card__progress {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.subagent-status-card__details section {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -11960,6 +11998,16 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-scope__popover label:has(input:checked) {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-scope__popover label:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.knowledge-scope__popover label input {
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@ const conversationSubagentActivityBaseSchema = z.object({
|
||||
'cancelled'
|
||||
]),
|
||||
reason: z.string().trim().min(1).max(240).optional(),
|
||||
progress: conversationMessageBlocksSchema.optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().trim().min(1).max(1_000).optional()
|
||||
})
|
||||
@@ -573,6 +574,7 @@ export type WorkspaceChanges = {
|
||||
available: boolean
|
||||
status: string
|
||||
patch: string
|
||||
stagedPatch?: string
|
||||
files: WorkspaceChangedFile[]
|
||||
truncated: boolean
|
||||
error?: string
|
||||
|
||||
@@ -1893,6 +1893,7 @@ export type DesktopApi = {
|
||||
}
|
||||
workspace: {
|
||||
getChanges: (projectId: string) => Promise<WorkspaceChanges>
|
||||
getFileDiff: (projectId: string, path: string) => Promise<WorkspaceChanges>
|
||||
listDirectory: (
|
||||
projectId: string,
|
||||
path: string
|
||||
|
||||
@@ -180,6 +180,7 @@ export const ipcChannels = {
|
||||
conversationQueueChanged: 'conversation-queue:changed',
|
||||
conversationQueueDispatch: 'conversation-queue:dispatch',
|
||||
workspaceChangesGet: 'workspace:changes:get',
|
||||
workspaceFileDiff: 'workspace:file:diff',
|
||||
workspaceDirectoryList: 'workspace:directory:list',
|
||||
workspaceFileRead: 'workspace:file:read',
|
||||
workspacePathOpen: 'workspace:path:open',
|
||||
|
||||
@@ -802,6 +802,7 @@ export const remoteGitStatusRequestSchema = z
|
||||
|
||||
export const remoteGitFileStatusSchema = z
|
||||
.object({
|
||||
statusCode: z.string().regex(/^[ .AMDRCUT?!]{2}$/u).optional(),
|
||||
relativePath: remoteRelativePathSchema,
|
||||
index: z.enum([
|
||||
'unmodified',
|
||||
|
||||
Reference in New Issue
Block a user