Repository navigation
Render workspace handoff as a single transition, not a fake user turn - #69
Conversation
A workspace handoff needs two provider turns: the current turn must end before the provider can restart in the newly bound worktree. That boundary is a correctness requirement, but it leaked into the UI as a user-message bubble containing T3's fixed continuation boilerplate plus a queued message the user never wrote. Tag handoff-authored messages and queued turns with a structured origin and use it to present the move as what it is: - The decider emits a system marker message on both handoff paths -- when it creates a continuation, and when it reuses an existing user-queued turn -- so the marker is the invariant of a handoff. - The transcript renders the marker as a compact "Moved to <branch>" divider and suppresses the boilerplate continuation bubble. The suppressed row's revert anchor transfers onto the marker so the post-handoff turn stays revertible. - The marker is emitted mid-turn, so it is deferred to the next turn boundary; otherwise it splits the requesting turn's work rows and inflates its elapsed time. - The composer hides a healthy handoff continuation but keeps a failed one visible and actionable, since deleting it would strand the thread in a worktree with nothing queued to run. Queue labels use real dispatch positions so a hidden continuation cannot mislabel the user's own next message. Origin is matched structurally rather than by string-matching the continuation prompt, so rewording the prompt cannot silently expose it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds structured workspace-handoff metadata so the UI presents handoffs as transitions rather than synthetic user turns.
Changes:
- Adds and persists handoff origins for markers and continuations.
- Renders move dividers while hiding healthy continuation plumbing.
- Adds migration and cross-layer tests.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
packages/contracts/src/orchestration.ts |
Defines handoff origins and command metadata. |
apps/web/src/types.ts |
Exposes message origins to the UI. |
apps/web/src/store.ts |
Maps projected origins into chat messages. |
apps/web/src/components/chat/QueuedMessagesPanel.tsx |
Hides healthy handoff continuations. |
apps/web/src/components/chat/QueuedMessagesPanel.test.tsx |
Tests queue filtering and labels. |
apps/web/src/components/chat/MessagesTimeline.tsx |
Renders workspace-move dividers. |
apps/web/src/components/chat/MessagesTimeline.test.tsx |
Tests marker rendering and suppression. |
apps/web/src/components/chat/MessagesTimeline.logic.ts |
Derives and positions handoff rows. |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts |
Tests timeline grouping, reverts, and stability. |
apps/web/src/components/chat/chatFind.ts |
Makes handoff rows searchable. |
apps/server/src/persistence/Services/ProjectionThreadMessages.ts |
Adds projected message origins. |
apps/server/src/persistence/Services/ProjectionQueuedTurns.ts |
Adds projected queued-turn origins. |
apps/server/src/persistence/Migrations/054_ProjectionWorkspaceHandoffOrigin.ts |
Adds origin columns. |
apps/server/src/persistence/Migrations/054_ProjectionWorkspaceHandoffOrigin.test.ts |
Tests migration and replay safety. |
apps/server/src/persistence/Migrations.ts |
Registers migration 54. |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts |
Persists message origins. |
apps/server/src/persistence/Layers/ProjectionQueuedTurns.ts |
Persists queued-turn origins. |
apps/server/src/orchestration/projector.ts |
Projects origins into the read model. |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts |
Restores origins in snapshots. |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts |
Propagates origins through projections. |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts |
Tests origin round-tripping. |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts |
Updates handoff command fixture. |
apps/server/src/orchestration/decider.ts |
Emits markers and forwards continuation origins. |
apps/server/src/orchestration/decider.queue.test.ts |
Tests both handoff paths and dispatch. |
apps/server/src/cli.ts |
Tags CLI-created handoff continuations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| markerMessageId: MessageId, | ||
| continuation: OrchestrationQueuedTurn, |
| } else { | ||
| const revertTurnCount = input.revertTurnCountByUserMessageId.get(message.id); | ||
| const lastPendingRow = pendingHandoffRows.at(-1); | ||
| if (lastPendingRow && revertTurnCount !== undefined) { | ||
| lastPendingRow.revertMessageId = message.id; | ||
| lastPendingRow.revertTurnCount = revertTurnCount; | ||
| } |
…e marker Two issues from PR review: The decider constructed the marker origin itself but copied the continuation's origin verbatim from the command. That left the tag that suppresses the boilerplate bubble under caller control: a schema-valid caller could omit it and re-expose the bubble, or send role "marker" and render the continuation as a second divider. Both origins are now derived from the command's branch/worktreePath, so they cannot disagree with each other or with the thread's binding, and the CLI no longer supplies one. The hidden continuation is a user-role message, so it moved lastDurationBoundary to its own timestamp before the handoff branch ran. The post-handoff assistant row then reported elapsed time from a row the user cannot see, disagreeing with its own reasoning row, which was already anchored to the marker. The boundary now resets to the marker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Both findings were valid and are fixed in 13c4eef. 1. Continuation origin was caller-controlled. Correct, and it was an inconsistency in my own design: the decider constructed the marker origin itself but copied the continuation origin verbatim from the command. That left the tag that suppresses the boilerplate bubble under caller control — omit it and the bubble comes back, or send Fixed by deriving both origins from the command's 2. Duration boundary not reset for the hidden continuation. Also correct, and my earlier test missed it because it only asserted the reasoning row. The continuation is a user-role message, so it set New tests: a decider case passing a deliberately mistagged origin (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts:58
- The continuation is still inserted into
projection_thread_message_fts: the existing triggers index every settledusermessage without checkingorigin_json(048_ProjectionThreadMessageSearch.ts:18-24), andsearchTranscriptreturns that text as a user excerpt (ProjectionSnapshotQuery.ts:1885-1924). Global transcript search therefore exposes the boilerplate this tag is intended to hide, while the real marker is not indexed because it is asystemmessage. Update migration 054 to replace/rebuild the FTS triggers so continuations are excluded and the marker's branch/path can represent the transition.
origin_json,
apps/server/src/orchestration/decider.ts:720
- The origin is not consumed by the Markdown transcript exporter:
threadMarkdownExport.tsstill formats every message and every queued-turn text (lines 203-230 and 258-324), with queued turns enabled by default. A pending or dispatched handoff therefore still exports the fixed continuation as user-authored content and the marker as a separate message, rather than the promised single transition. Render the marker once, suppress healthy handoff continuations, and retain failed continuations for diagnosis.
queuedTurn: {
...command.continuation,
origin: { ...handoffOrigin, role: "continuation" },
},
The chat UI hid the boilerplate continuation, but two other surfaces still rendered it as a turn the user never wrote. Transcript search indexed it. The FTS triggers index every settled user/assistant message, and a dispatched continuation is one, so searching the boilerplate surfaced it -- and because the query keeps one excerpt per thread, it could stand in as that thread's user-authored excerpt. Migration 055 recreates the triggers with handoff origins excluded and unindexes rows already written. The marker is deliberately still not indexed: the transcript search result schema accepts only user/assistant roles, so a system-role row would fail to decode and break search for the whole thread. Markdown export formatted every message by role, reproducing the same fake turn in `t3 chat export markdown`. The exporter now renders the marker as a "Workspace moved" transition that does not consume a turn number, and drops the dispatched continuation while keeping the work that followed it. Queued continuations are unchanged and keep their origin in the queued-turns table, since a failed one is the recovery context for a thread stranded in a new worktree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Both valid — I had fixed the transcript and composer but missed the other two surfaces that render the same messages. Fixed in 24a420e. 1. Transcript search. Confirmed: the migration 048 triggers index every settled On representing the marker: I deliberately did not index it. 2. Markdown export. Confirmed. The exporter now renders the marker as a
Tests: three migration cases (new inserts excluded, previously-indexed rows removed on upgrade, ordinary messages still index and update correctly) and an export case asserting the boilerplate is absent while the post-move work and turn numbering survive.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/server/src/orchestration/decider.ts:140
messageForkEventsrebuilds eachthread.message-sentpayload without forwardingmessage.origin(lines 99–125). Forking a thread after a handoff therefore strips both tags: the marker becomes an ordinary system bubble and the boilerplate continuation reappears as a user message in the fork. Preserve the origin while cloning messages and cover a fork that includes a completed handoff.
readonly origin?: MessageSentPayload["origin"];
|
@copilot resolve the merge conflicts in this pull request |
|
Follow-up fix in #82: this change was ineffective in a live session. The live |
Problem
A workspace handoff needs two provider turns: the current turn must end before the provider can restart in the newly bound worktree. That boundary is a real correctness requirement — one provider process must never operate across two checkouts mid-turn — but it leaked straight into the transcript.
The user saw a message bubble they never wrote, containing T3's fixed continuation boilerplate (
WORKSPACE_HANDOFF_CONTINUATION_PROMPT), plus a matching entry in the composer's queued-messages panel. The one thing that actually happened — the thread moved to a different branch and checkout — was never stated anywhere.Approach
Keep the turn boundary; hide the plumbing. Handoff-authored messages and queued turns are tagged with a structured
origin(kind: "workspace-handoff",role: "marker" | "continuation",branch,worktreePath), and the UI reads that tag rather than string-matching the prompt — so rewording the prompt cannot silently expose it.Decider. Emits a system marker message on both handoff paths: when it creates a continuation, and when it reuses an existing user-queued turn. The marker is the invariant of a handoff; the continuation is the variable part.
Transcript. The marker renders as a compact
Moved to <branch>divider (worktree path in the tooltip) and the boilerplate continuation bubble is suppressed.Composer. A healthy handoff continuation is hidden. A failed one stays visible and actionable — deleting it outright would strand the thread in a new worktree with nothing queued to run.
Details worth reviewing
createdAt, a naively placed marker lands among the requesting turn's work rows — splitting its reasoning group and inflating its "Worked for" duration. The marker is deferred to the next turn boundary instead.queuedTurns[0], so filtering it out and labelling by filtered index would mislabel the user's own next message as "Up next".isRowUnchangedto keep the external-store snapshot referentially stable (React error Add drag-and-drop project reordering to the sidebar pingdotgg/t3code#185).053_.Testing
Tests added at every layer: decider event emission on both paths and origin forwarding through dispatch, projection round-trip, migration idempotency, timeline derivation (marker deferral, reasoning-group integrity, revert-anchor transfer, elapsed-time anchoring), and queue-panel filtering/labelling.
pnpm fmt:check,pnpm lint,pnpm typecheck, andpnpm testall pass (14/14 tasks, 1350 tests).