Skip to content

Render workspace handoff as a single transition, not a fake user turn - #69

Merged
ronak-guliani merged 4 commits into
mainfrom
feat/workspace-handoff-ux
Jul 29, 2026
Merged

ronak-guliani merged 4 commits into
mainfrom
feat/workspace-handoff-ux

Conversation

@ronak-guliani

Copy link
Copy Markdown
Owner

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

  • The marker is emitted mid-turn, when the provider calls the MCP tool, not at the turn boundary. Since the timeline sorts by 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.
  • Suppressing the continuation removed a revert anchor. Revert UI renders only on user rows, keyed by message ID, so hiding the continuation dropped the only way to revert the post-handoff turn. That anchor transfers onto the marker row.
  • Queue labels use real dispatch positions. The hidden continuation is always queuedTurns[0], so filtering it out and labelling by filtered index would mislabel the user's own next message as "Up next".
  • Marker rows are compared by field in isRowUnchanged to keep the external-store snapshot referentially stable (React error Add drag-and-drop project reordering to the sidebar pingdotgg/t3code#185).
  • Migration ID 54, not 49: the ledger is globally append-only and history contains migrations through 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, and pnpm test all pass (14/14 tasks, 1350 tests).

Note: the pre-commit React Doctor hook flags 6 js-combine-iterations warnings in cli.ts, ProjectionPipeline.ts, and Migrations.ts. All are pre-existing lines in untouched code — the hook scans whole staged files rather than the diff, and main already reports 39 such chains repo-wide.

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>
Copilot AI review requested due to automatic review settings July 28, 2026 22:17
@github-actions github-actions Bot added size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Jul 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +602 to 603
markerMessageId: MessageId,
continuation: OrchestrationQueuedTurn,
Comment on lines +204 to +210
} 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>
@ronak-guliani

Copy link
Copy Markdown
Owner Author

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 role: "marker" and the continuation renders as a second divider.

Fixed by deriving both origins from the command's branch/worktreePath in the decider, so they cannot disagree with each other or with the binding the same command writes. The CLI no longer supplies one. I preferred this to constraining the schema: a validation rule would still need the decider to cross-check the origin against branch/worktreePath, whereas deriving it makes the mismatch unrepresentable.

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 lastDurationBoundary to its own timestamp before the handoff branch ran — the assistant row then measured from a row the user cannot see, disagreeing with its own reasoning row. The boundary now resets to the matched marker.

New tests: a decider case passing a deliberately mistagged origin (role: "marker", stale branch/path) and asserting the derived value wins; and a timeline case asserting durationStart on the post-handoff assistant row equals the marker's createdAt. I verified the latter fails without the fix rather than assuming it.

fmt:check, lint, typecheck, test all green (1351 tests).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 settled user message without checking origin_json (048_ProjectionThreadMessageSearch.ts:18-24), and searchTranscript returns 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 a system message. 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.ts still 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>
@ronak-guliani

Copy link
Copy Markdown
Owner Author

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 user/assistant message with no origin_json check, and a dispatched continuation is exactly that. Migration 055 recreates the three triggers with handoff origins excluded and unindexes rows already written, so existing databases are corrected rather than only new writes.

On representing the marker: I deliberately did not index it. TranscriptSearchRowSchema types role as Schema.Literals(["user", "assistant"]), so indexing a system-role marker would fail to decode and break search for the entire thread — not just that row. Making markers searchable is worthwhile but needs the result schema and the palette's role rendering widened first, so it belongs in its own change.

2. Markdown export. Confirmed. The exporter now renders the marker as a ### Workspace moved transition and drops the dispatched continuation. Two details worth noting:

  • The marker has a null turnId, so a naive filter left it as a stray Turn N: no turn id group. Transitions no longer consume a turn number, so turn numbering stays continuous across a handoff.
  • Only the dispatched continuation is dropped. Queued continuations are untouched and now carry an Origin row in the queued-turns table — a failed one is the recovery context for a thread stranded in a new worktree, which is the case you flagged.

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.

fmt:check, lint, typecheck, test all green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • messageForkEvents rebuilds each thread.message-sent payload without forwarding message.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"];

@ronak-guliani

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts in this pull request

@ronak-guliani
ronak-guliani merged commit f5eed98 into main Jul 29, 2026
5 of 8 checks passed
Copilot stopped work on behalf of ronak-guliani due to an error July 29, 2026 00:19
@ronak-guliani

Copy link
Copy Markdown
Owner Author

Superseded by #82, which contains these commits plus the fix for the bug that made this PR ineffective in a live session: the live thread.message-sent handler dropped origin, so the marker rendered as a raw system bubble and the continuation bubble survived until a reload. Closing in favour of #82.

@ronak-guliani

Copy link
Copy Markdown
Owner Author

Follow-up fix in #82: this change was ineffective in a live session. The live thread.message-sent handler in the web store rebuilt messages field by field and dropped origin, so the marker rendered as a raw system bubble and the continuation bubble survived until a reload. The tests here fed hand-built origins straight into the timeline, so the store was never on the path under test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants