feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(web): add cross-provider model handoff with structured context - #7101

Closed
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff
Closed

feat(web): add cross-provider model handoff with structured context#7101
kbrianps wants to merge 1 commit into
pingdotgg:mainfrom
kbrianps:feat/cross-provider-handoff

Conversation

@kbrianps

@kbrianpskbrianps commented Aug 15, 2026

Copy link
Copy Markdown

Summary

This PR implements cross-provider model switching via structured thread handoffs.

Problem

Previously, once a thread started, switching to a different provider or incompatible driver (e.g. Claude Code ↔ Codex, Antigravity, etc.) was blocked because runtime session cursors and subprocess lifecycles are mutually incompatible across providers. Removing the lock directly was unsafe and could cause empty sessions or out-of-sync transcripts.

Solution

  • Handoff Fork: When selecting an incompatible provider or driver in an active conversation, the user is offered a structured handoff dialog (ThreadHandoffDialog) instead of being blocked.
  • Deterministic Context Synthesis: Implemented buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff to compile canonical goal, decisions, modified files, terminal commands, active plans, and latest requests without relying on external LLM summarization.
  • Transparency & Control: The user can review, edit, or adjust the synthesized context before continuing into the new thread.
  • Thread Integrity: The source thread remains untouched and accessible.

Verification

  • Added comprehensive unit tests in @t3tools/client-runtime for Claude ↔ Codex transitions and offline source threads.
  • Added component tests for ThreadHandoffDialog in @t3tools/web.
  • Validated with vp fmt, vp lint, and test suites.

Note

Medium Risk
Touches thread create/start/navigation and model-lock behavior on live conversations; failures are partially mitigated by cleanup and toasts, but a bad handoff could still start the wrong model or lose context if synthesis is incomplete.

Overview
Cross-provider model switching on active server threads no longer hard-blocks incompatible picks in the composer. Choosing a different driver/continuation group—or a model change that would normally be blocked on a started thread—opens ThreadHandoffDialog so the user can review and edit context, then continue in a new thread; the original thread is left unchanged.

buildThreadHandoffMarkdown in @t3tools/client-runtime/handoff deterministically assembles handoff markdown from the source thread (messages, activities, checkpoints, plans, etc.). Confirming handoff creates a titled fork (… (Handoff)), starts the first turn with the markdown as the user message, waits for the thread to start, navigates to it, and deletes the new thread if anything fails.

The model picker gains an optional allowHandoff mode: cross-provider rows show a Handoff badge and stay selectable instead of being filtered/disabled when the provider is locked. Unit and component tests cover markdown generation and dialog rendering.

Reviewed by Cursor Bugbot for commit e55a6cc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add cross-provider model handoff with structured context in chat threads

  • When a user selects a model from a different provider (or continuation group) in an active server thread, a handoff dialog opens instead of blocking the selection.
  • The dialog displays and allows editing of structured handoff markdown (built by buildThreadHandoffMarkdown in packages/client-runtime/src/handoff/threadHandoff.ts) that captures thread context such as the original goal, decisions, modified files, and latest request.
  • On confirmation, a new server thread is created with the handoff message as the seed, the UI navigates to it, and a success toast is shown; failures show an error toast and attempt to delete the created thread.
  • The model picker now shows cross-provider models as selectable with a 'Handoff' badge rather than filtering or disabling them when allowHandoff is true.
  • Behavioral Change: models that were previously disabled in the picker for started server threads are now enabled, and cross-provider selections trigger the handoff flow instead of a warning.
📊 Macroscope summarized e55a6cc. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

CopilotAI lite review requested due to automatic review settings August 15, 2026 14:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2fe53a21-7040-4a94-aaea-14e62203fab7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 15, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI consistency review found three issues in the new ThreadHandoffDialog, all in apps/web/src/components/chat/ThreadHandoffDialog.tsx: a non-existent Button variant, a hand-rolled dialog header that bypasses the Dialog* primitives (and their aria wiring/spacing contract), and a textarea label that is not associated with its control plus a no-op resize-y on the Textarea wrapper.

Posted via Macroscope — UI Consistency

Comment on lines +69 to +77
<div className="flex flex-col space-y-1.5 p-6 pb-0">
<h2 className="text-lg font-semibold leading-none tracking-tight">
Continue in new thread
</h2>
<p className="text-sm text-muted-foreground">
Switch to a different model with a structured handoff of your current progress. The
original thread remains untouched.
</p>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The header is hand-rolled instead of using the shared DialogHeader / DialogTitle / DialogDescription primitives. Two concrete consequences: Base UI only wires aria-labelledby / aria-describedby on the popup when Dialog.Title / Dialog.Description are rendered, so this dialog ships with no accessible name; and without a data-slot="dialog-header" element the DialogPanel spacing contract (in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1) never applies, which is why the call site has to re-specify p-6 pb-0 and px-6 py-4. The title typography also diverges from DialogTitle (text-lg vs font-heading text-xl).

Suggested fix: import DialogHeader, DialogTitle, DialogDescription from ../ui/dialog and drop the manual padding overrides on the header and DialogPanel. Note Dialog.Title requires a dialog root, so ThreadHandoffDialog.test.tsx should render ThreadHandoffContent inside <Dialog open> / DialogPopup.

- <div className="flex flex-col space-y-1.5 p-6 pb-0">- <h2 className="text-lg font-semibold leading-none tracking-tight">- Continue in new thread- </h2>- <p className="text-sm text-muted-foreground">- Switch to a different model with a structured handoff of your current progress. The- original thread remains untouched.- </p>- </div>+ <DialogHeader>+ <DialogTitle>Continue in new thread</DialogTitle>+ <DialogDescription>+ Switch to a different model with a structured handoff of your current progress. The+ original thread remains untouched.+ </DialogDescription>+ </DialogHeader>

Posted via Macroscope — UI Consistency

Cancel
</Button>
<Button
variant="primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Button has no primary variant (default | destructive | destructive-outline | ghost | ghost-muted | glass | link | outline | secondary), so this fails typecheck and, at runtime, cva applies no variant classes — the confirm CTA renders with only the base classes (no background, no primary border/shadow) next to the outlined Cancel button. The filled primary style is variant="default".

Suggested change
variant="primary"
variant="default"

Posted via Macroscope — UI Consistency

Comment on lines +112 to +122
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Handoff context (editable)
</label>
<Textarea
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
rows={12}
className="font-mono text-xs leading-relaxed resize-y"
placeholder="Structured context to be sent to the new model..."
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues here. The raw <label> has no htmlFor and the Textarea has no id, so clicking the label does not focus the control and the textarea has no accessible name; elsewhere the repo pairs the Label primitive with an id (e.g. projectScriptEditor.tsx). Also resize-y lands on the Textarea wrapper <span>, not the inner textarea, so it has no effect — the inner element is field-sizing-content (which also makes rows={12} non-authoritative); target it via [&_[data-slot=textarea]]: as DiffCommentAnnotation.tsx does.

Suggested fix (also import Label from ../ui/label):

- <label className="text-xs font-medium text-muted-foreground">- Handoff context (editable)- </label>+ <Label htmlFor="thread-handoff-context" className="text-xs text-muted-foreground">+ Handoff context (editable)+ </Label>
<Textarea
+ id="thread-handoff-context"
value={handoffText}
onChange={(e) => setHandoffText(e.target.value)}
- rows={12}- className="font-mono text-xs leading-relaxed resize-y"+ className="font-mono text-xs leading-relaxed [&_[data-slot=textarea]]:max-h-80 [&_[data-slot=textarea]]:resize-y"

Posted via Macroscope — UI Consistency

);
}, [providerInstanceEntries, targetModelSelection]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:52

Routine sourceThread refreshes overwrite the user's edited handoffText while the dialog is open. The effect depends on the entire sourceThread object, so any store update that replaces it regenerates the markdown and calls setHandoffText; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 52:
Routine `sourceThread` refreshes overwrite the user's edited `handoffText` while the dialog is open. The effect depends on the entire `sourceThread` object, so any store update that replaces it regenerates the markdown and calls `setHandoffText`; only reset for a newly opened handoff or changed thread/target, while preserving dirty input.

setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

const handleConfirm = useCallback(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ThreadHandoffDialog.tsx:60

A rapid double activation of handleConfirm invokes onConfirmHandoff twice before isSubmitting disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside handleConfirm; relying on the next render's disabled value does not prevent duplicate submissions.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ThreadHandoffDialog.tsx around line 60:
A rapid double activation of `handleConfirm` invokes `onConfirmHandoff` twice before `isSubmitting` disables the button, so the caller can create two fork threads. Add an in-flight ref or synchronous state check inside `handleConfirm`; relying on the next render's `disabled` value does not prevent duplicate submissions.

failure = startResult._tag === "Failure" ? startResult : null;
}

if (failure === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5935

A handoff whose new thread never starts is still navigated to and reported as successful. waitForStartedServerThread resolves false on timeout, but this code treats every Success as success; handle the false value as a failure before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5935:
A handoff whose new thread never starts is still navigated to and reported as successful. `waitForStartedServerThread` resolves `false` on timeout, but this code treats every `Success` as success; handle the `false` value as a failure before navigation.

keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
allowHandoff = true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:101

Locked pickers default to showing an enabled cross-provider Handoff row, but selecting that row can still be rejected by handleModelSelect when getModelDisabledReason reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default allowHandoff to false and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

Suggested change
allowHandoff=true,
allowHandoff=false,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 101:
Locked pickers default to showing an enabled cross-provider `Handoff` row, but selecting that row can still be rejected by `handleModelSelect` when `getModelDisabledReason` reports that handoff is unsupported, so the UI presents an action that silently does nothing. Default `allowHandoff` to `false` and require callers to opt in only when their selection handler supports handoff, keeping the row filtering and handler decision consistent.

// ignoring sidebar selection so account-scoped searches can find a
// model before the user chooses a specific instance rail item.
if (props.lockedProvider !== null) {
if (!allowHandoff && props.lockedProvider !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumchat/ModelPickerContent.tsx:319

filteredModels can use a stale model list when allowHandoff changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because allowHandoff is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add allowHandoff to the dependency array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ModelPickerContent.tsx around line 319:
`filteredModels` can use a stale model list when `allowHandoff` changes, leaving cross-provider models visible after handoffs are disabled or filtering them after handoffs are enabled. Because `allowHandoff` is read in the memo but omitted from its dependency array, React does not recompute until another dependency changes. Add `allowHandoff` to the dependency array.

`> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
);

const messages = (thread.messages ?? []).filter((m) => m.text.trim().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highhandoff/threadHandoff.ts:56

Requests conveyed through message attachments are dropped, so a message with blank text produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty message.text, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/handoff/threadHandoff.ts around line 56:
Requests conveyed through message `attachments` are dropped, so a message with blank `text` produces no original goal or latest request and the new provider receives no user-supplied task context. The filter only retains non-empty `message.text`, and the handoff never reads attachments; include the attachment data (and preserve messages whose task is conveyed only by attachments) when building the handoff.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onOpenChange(false);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog closes after failed handoff

High Severity

ThreadHandoffDialog always calls onOpenChange(false) after await onConfirmHandoff(...), but handleConfirmHandoff swallows failures with a toast and returns normally. Failed creates/starts therefore dismiss the dialog and discard the user’s edited handoff text, unlike PullRequestThreadDialog which only closes on success.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

targetModelSelection,
});
setHandoffText(generated);
}, [sourceThread, targetModelSelection]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handoff edits wiped by updates

High Severity

ThreadHandoffContent regenerates and replaces handoffText whenever sourceThread changes. activeServerThread from useThread gets a new object on shell/detail updates (streaming, session, settle, title), so live thread churn overwrites user edits in the textarea while the dialog is open.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

onCancel={() => onOpenChange(false)}
onConfirmHandoff={handleConfirm}
/>
</DialogPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dialog dismissible while submitting

Medium Severity

While isSubmitting is true, Cancel is disabled but Dialog still forwards Escape, backdrop, and the close button through onOpenChange. The handoff create/start continues in the background after dismiss, so a later failure only surfaces as a toast with the edited context already gone.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit e55a6cc. Configure here.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a significant new feature (cross-provider model handoff) with new UI components, thread creation workflows, and user-facing behavior. Multiple High and Medium severity findings remain unresolved, including issues with timeout handling, attachment preservation, and dialog state management.

You can customize Macroscope's approvability policy. Learn more.

@t3-code

t3-codeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

closing this because it will be included with orchestrator v2.

@t3-codet3-codeBot closed this Aug 15, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@kbrianps