What this prevents
- I can start a chat from Home, ask the agent to build something, and be guided to attach or create a workspace before anything else happens.
- I can clearly tell when the agent is waiting on me versus when it has actually finished.
- I can send a follow-up message in a workspaceless chat without silently cancelling the agent's pending question to me.
When a user opens a chat that is not bound to a project (for example, from the Home page) and asks the agent to build something, the agent correctly recognizes it has no workspace and calls the request_workspace tool. The tool publishes a workspace_attach_required event and renders inline in the chat as a WorkspaceAttachCard. In practice the user does not notice the inline card. They send another message instead, which takes over the chat lock, cancels the pending request, and the chat thread shows a bare "Agent task completed" with no body. The user reads this as "the agent is broken", not "the agent is waiting on me to pick a workspace". The capability the agent is offering (attach an existing workspace or create a new empty one) is invisible.
The fix is a UX change: when the agent calls request_workspace, the frontend should open a modal/popup that blocks the chat composer and presents the two clear choices ("Attach existing workspace" / "Create new workspace"), rather than rely on an inline card the user can scroll past or cancel by sending another message. The agent should not appear to "complete" successfully in this state.
Surface
- Backend
request_workspace tool publishes the workspace_attach_required SSE event with input_id + candidate workspace list.
- Approval bookkeeping in the pending-user-input manager tracks the request as kind
workspace_attach.
- Frontend currently renders the
WorkspaceAttachCard inline in the chat container when the message type is workspace_attach_request.
- Frontend SSE handling receives
workspace_attach_required in the agent-chat hook.
- Worker chat-lock semantics: when a new agent task is enqueued for the same chat, it cancels the in-flight one, including the pending
request_workspace await.
Repro
- Open a chat from Home (do NOT enter a project first). The chat will have
project_id empty.
- Ask the agent something concrete that requires writing files, e.g. "build me an mvp for my shoe business".
- Observe the inline WorkspaceAttachCard render briefly (or not at all on slow connections).
- Without clicking either button on the card, send a follow-up message ("just pick randomly i need a basic mvp").
- Observe: the previous task is cancelled, the new task again calls
request_workspace, and the user sees only repeated "Agent task completed" entries with no body.
Trace pattern from this scenario:
[PendingUserInputManager] created workspace_attach request <id> session=<chat>
[WORKER] Lost chat lock for <chat>, task <id> — signalling cancellation
Chat lock taken over from cancelled zombie: <chat> by <new task>
[PendingUserInputManager] created workspace_attach request <new id> session=<chat>
[WORKER] Lost chat lock for <chat>, task <new task> — signalling cancellation
Backend POSTs from this chat all carry an empty project_id.
Expected vs Actual
Expected:
- When the agent calls
request_workspace in a workspaceless chat, the frontend opens a modal that blocks the chat composer and presents "Attach existing workspace" / "Create new workspace" / "Cancel".
- Sending another chat message while the modal is open is blocked (or queued behind the user's choice), so the pending request is not silently cancelled.
- If the user explicitly cancels, the chat shows a clear "Cancelled: workspace not attached" status, not "Agent task completed".
Actual:
- The frontend renders an inline
WorkspaceAttachCard in the chat thread, which can be missed, scrolled past, or invalidated when the next message arrives.
- The chat composer remains active. A follow-up message preempts the pending request and the user sees only "Agent task completed" with no body.
- A user with no prior context cannot tell the agent is waiting on them.
Suggested fix
Two-part change.
-
Frontend: promote workspace_attach_required from an inline chat card to a blocking modal.
- Listen for
workspace_attach_required in the agent-chat hook and surface a global modal (e.g., a new WorkspaceAttachModal reusing the existing card body) gated by the input_id so it auto-dismisses on workspace_attach_resumed / workspace_attach_cancelled.
- While the modal is open, disable the chat composer for that chat (with a tooltip "Pick a workspace to continue").
- Keep
WorkspaceAttachCard rendered inline in the message history for audit trail, but the user-facing CTA lives in the modal.
-
Backend: stop letting a follow-up user message in the same chat silently cancel a pending workspace_attach request.
- In the agent-stream entry, if the chat already has an open pending-user-input request of kind
workspace_attach, do not enqueue a new agent task. Instead return a 409-ish response (or push a chat event) telling the frontend "answer the workspace prompt first".
- Alternatively, make the worker re-emit the existing
workspace_attach_required event on the new SSE stream so the new modal opens with the same input_id.
Either of these is enough on its own to remove the silent-failure surface. The frontend modal change alone covers the common path; the backend guard covers the case where the modal was dismissed or never received.
What this prevents
When a user opens a chat that is not bound to a project (for example, from the Home page) and asks the agent to build something, the agent correctly recognizes it has no workspace and calls the
request_workspacetool. The tool publishes aworkspace_attach_requiredevent and renders inline in the chat as aWorkspaceAttachCard. In practice the user does not notice the inline card. They send another message instead, which takes over the chat lock, cancels the pending request, and the chat thread shows a bare "Agent task completed" with no body. The user reads this as "the agent is broken", not "the agent is waiting on me to pick a workspace". The capability the agent is offering (attach an existing workspace or create a new empty one) is invisible.The fix is a UX change: when the agent calls
request_workspace, the frontend should open a modal/popup that blocks the chat composer and presents the two clear choices ("Attach existing workspace" / "Create new workspace"), rather than rely on an inline card the user can scroll past or cancel by sending another message. The agent should not appear to "complete" successfully in this state.Surface
request_workspacetool publishes theworkspace_attach_requiredSSE event withinput_id+ candidate workspace list.workspace_attach.WorkspaceAttachCardinline in the chat container when the message type isworkspace_attach_request.workspace_attach_requiredin the agent-chat hook.request_workspaceawait.Repro
project_idempty.request_workspace, and the user sees only repeated "Agent task completed" entries with no body.Trace pattern from this scenario:
Backend POSTs from this chat all carry an empty
project_id.Expected vs Actual
Expected:
request_workspacein a workspaceless chat, the frontend opens a modal that blocks the chat composer and presents "Attach existing workspace" / "Create new workspace" / "Cancel".Actual:
WorkspaceAttachCardin the chat thread, which can be missed, scrolled past, or invalidated when the next message arrives.Suggested fix
Two-part change.
Frontend: promote
workspace_attach_requiredfrom an inline chat card to a blocking modal.workspace_attach_requiredin the agent-chat hook and surface a global modal (e.g., a newWorkspaceAttachModalreusing the existing card body) gated by theinput_idso it auto-dismisses onworkspace_attach_resumed/workspace_attach_cancelled.WorkspaceAttachCardrendered inline in the message history for audit trail, but the user-facing CTA lives in the modal.Backend: stop letting a follow-up user message in the same chat silently cancel a pending
workspace_attachrequest.workspace_attach, do not enqueue a new agent task. Instead return a 409-ish response (or push a chat event) telling the frontend "answer the workspace prompt first".workspace_attach_requiredevent on the new SSE stream so the new modal opens with the sameinput_id.Either of these is enough on its own to remove the silent-failure surface. The frontend modal change alone covers the common path; the backend guard covers the case where the modal was dismissed or never received.