Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions ui/src/features/agent/AgentAssistantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
type AgentActionResult,
} from './agentActions'
import { applyPollToCard, cardsFromResults, tabForExecutionTarget, type WizardExecutionCard } from './executionCards'
import { applyRemoteWizardConversation, WIZARD_WELCOME_TEXT } from './wizardConversationSync'
import {
applyRemoteWizardConversation,
isWizardConversationWriteCurrent,
shouldFollowWizardWorkspace,
WIZARD_WELCOME_TEXT,
} from './wizardConversationSync'
import { AgentMarkdown } from './AgentMarkdown'
import { defaultWizardWorkflowRuntime, type WizardWorkflowPendingInput, type WizardWorkflowRecord } from './wizardWorkflowRuntime'
import { ensureRhythmic3dWorkflowRegistered } from './rhythmic3dWorkflow'
Expand Down Expand Up @@ -201,14 +206,15 @@ export function AgentAssistantPanel({ workspace, tasks, onClose }: AgentAssistan
messages,
executions: cards,
}).then(saved => {
if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return
conversationRevisionRef.current = saved.revision
}).catch(async () => {
// A second tab may have advanced the CAS revision. Re-read and merge by
// message id; the resulting state triggers one save against the current
// backend revision. Local storage remains the fallback if this fails.
try {
const current = await fetchWizardConversation(conversationWorkspace)
if (!mountedRef.current || conversationWorkspaceRef.current !== conversationWorkspace) return
if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return
const choice = applyRemoteWizardConversation({
localMessages: messagesRef.current,
localRevision: conversationRevisionRef.current,
Expand All @@ -226,9 +232,10 @@ export function AgentAssistantPanel({ workspace, tasks, onClose }: AgentAssistan
}, [conversationWorkspace, hydratedWorkspace, messages])

useEffect(() => {
if (workspace !== conversationWorkspace) return
let cancelled = false
void fetchWizardConversation(workspace).then(payload => {
if (cancelled) return
void fetchWizardConversation(conversationWorkspace).then(payload => {
if (cancelled || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return
const choice = applyRemoteWizardConversation({
localMessages: messagesRef.current,
localRevision: conversationRevisionRef.current,
Expand All @@ -242,26 +249,21 @@ export function AgentAssistantPanel({ workspace, tasks, onClose }: AgentAssistan
// merge remote-only messages. Use a fresh array so the persistence
// effect retries the canonical save with that revision.
setMessages([...choice.messages] as AgentMessage[])
setHydratedWorkspace(workspace)
setHydratedWorkspace(conversationWorkspace)
}).catch(() => {
// Fall back to the local cache already loaded for this workspace.
if (!cancelled) setHydratedWorkspace(workspace)
if (!cancelled && isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) {
setHydratedWorkspace(conversationWorkspace)
}
})
return () => { cancelled = true }
}, [workspace])
}, [conversationWorkspace, workspace])

useEffect(() => {
if (workspace === conversationWorkspace) return
if (!shouldFollowWizardWorkspace({ activeWorkspace: workspace, conversationWorkspace, busy })) return
conversationRevisionRef.current = 0
skipNextConversationSaveRef.current = false
setHydratedWorkspace(null)
if (busy) {
// A Wizard action changed workspace while this turn was executing.
// Keep the visible turn alive and persist it in the destination so its
// real action result is not lost when the footer updates.
setConversationWorkspace(workspace)
return
}
setMessages(readMessages(workspace))
setConversationWorkspace(workspace)
setState('idle')
Expand Down
2 changes: 1 addition & 1 deletion ui/src/features/agent/agentKnowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Action and truthfulness rules:
- Use cancel_task only after an explicit cancel/stop request. Set confirm=true. Leave task_id empty to target the single active root task; if several are active, ask for the id instead of cancelling all.
- Use resume_task only after an explicit resume request, with confirm=true and a specific task_id when more than one resumable task exists.
- Use retry_task only after an explicit retry request, with confirm=true. Use task_id="latest" only when the user explicitly says latest/last failure; otherwise identify the exact task when several are retryable.
- Use select_workspace with an exact name from workspaces.available. Use create_workspace only after an explicit request to create a new workspace. A workspace change affects where outputs/tasks are read and written; the Wizard chat survives the transition. Never delete a workspace: no delete capability is implemented.
- Use select_workspace with an exact name from workspaces.available. Use create_workspace only after an explicit request to create a new workspace. A workspace change affects where outputs/tasks are read and written. The current turn stays visible until it finishes, then the destination loads its own Wizard chat. Never delete a workspace: no delete capability is implemented.
- Never delete files, run shell commands, change secrets or operate outside the listed actions. Explain that limitation plainly if asked.
- Prefer a direct answer, then numbered steps only when they genuinely help.

Expand Down
14 changes: 14 additions & 0 deletions ui/src/features/agent/wizardConversationSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,17 @@ export function applyRemoteWizardConversation(input: {
}
return { source: 'remote', messages: remoteMessages, revision: remoteRevision }
}

/** Follow the footer workspace only after the in-flight turn finishes. */
export function shouldFollowWizardWorkspace(input: {
activeWorkspace: string
conversationWorkspace: string
busy: boolean
}): boolean {
return input.activeWorkspace !== input.conversationWorkspace && !input.busy
}

/** Drop async conversation writes that finished after the owner changed. */
export function isWizardConversationWriteCurrent(owner: string, current: string): boolean {
return Boolean(owner) && owner === current
}
27 changes: 27 additions & 0 deletions ui/tests/agentContract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,33 @@ test('a stale remote snapshot does not replace a newer local Wizard turn', async
)
})

test('a busy workspace switch does not rebind or accept writes for another Wizard chat', async () => {
const {
isWizardConversationWriteCurrent,
shouldFollowWizardWorkspace,
} = await import('../src/features/agent/wizardConversationSync.ts')

assert.equal(shouldFollowWizardWorkspace({
activeWorkspace: 'consola-b',
conversationWorkspace: 'consola-b',
busy: false,
}), false)
assert.equal(shouldFollowWizardWorkspace({
activeWorkspace: 'consola-b',
conversationWorkspace: 'consola-a',
busy: true,
}), false)
assert.equal(shouldFollowWizardWorkspace({
activeWorkspace: 'consola-b',
conversationWorkspace: 'consola-a',
busy: false,
}), true)

assert.equal(isWizardConversationWriteCurrent('consola-a', 'consola-b'), false)
assert.equal(isWizardConversationWriteCurrent('consola-a', 'consola-a'), true)
assert.equal(isWizardConversationWriteCurrent('', 'consola-a'), false)
})

test('execution cards expose five controls and keep the same id on poll', async () => {
const { cardFromReport, applyPollToCard } = await import('../src/features/agent/executionCards.ts')
const card = cardFromReport({
Expand Down