From d4ff6d56702240fbefc333c390f9ac4e7fd50828 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:07:49 -0600 Subject: [PATCH 1/5] feat(client-runtime): fork a thread at its latest reply for /handoff Adds the shared /handoff rules (parse, title, when it can run, and the provider-command collision rule) and a client operation that dispatches the existing thread.fork at latest_stable into a listed thread. --- .../client-runtime/src/operations/commands.ts | 16 +++ .../src/state/threadCommands.ts | 10 ++ packages/shared/package.json | 4 + packages/shared/src/threadHandoff.test.ts | 103 ++++++++++++++++++ packages/shared/src/threadHandoff.ts | 76 +++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 packages/shared/src/threadHandoff.test.ts create mode 100644 packages/shared/src/threadHandoff.ts diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 777f7e2adb4f..82522bf37992 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -1008,6 +1008,22 @@ export const forkSideChat = Effect.fn("EnvironmentCommands.forkSideChat")(functi }); }); +/** Forks the thread's latest stable point into a new listed thread, for `/handoff`. */ +export const forkThreadForHandoff = Effect.fn("EnvironmentCommands.forkThreadForHandoff")( + function* (input: ForkSideChatInput) { + return yield* dispatch({ + type: "thread.fork", + commandId: yield* allocateCommandId(input), + createdBy: "user", + creationSource: input.creationSource ?? "web", + sourceThreadId: input.sourceThreadId, + targetThreadId: input.targetThreadId, + sourcePoint: { type: "latest_stable" }, + ...(input.title === undefined ? {} : { title: input.title }), + }); + }, +); + export const mergeThreadBack = Effect.fn("EnvironmentCommands.mergeThreadBack")(function* ( input: MergeThreadBackInput, ) { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 88a5759c5ed5..23d1a0a6e7d3 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -28,6 +28,7 @@ import { editQueuedRun, interruptThreadTurn, forkSideChat, + forkThreadForHandoff, forkThreadFromRun, markThreadUnread, mergeThreadBack, @@ -317,6 +318,15 @@ export function createThreadEnvironmentAtoms( key: ({ environmentId, input }) => JSON.stringify([environmentId, input.sourceThreadId]), }, }), + forkForHandoff: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:fork-for-handoff", + execute: forkThreadForHandoff, + scheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.sourceThreadId]), + }, + }), mergeBack: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:merge-back", execute: mergeThreadBack, diff --git a/packages/shared/package.json b/packages/shared/package.json index 19542c445909..634d9b1ed3a2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -375,6 +375,10 @@ "types": "./src/usageLimits.ts", "import": "./src/usageLimits.ts" }, + "./threadHandoff": { + "types": "./src/threadHandoff.ts", + "import": "./src/threadHandoff.ts" + }, "./desktopAppControl": { "types": "./src/desktopAppControl.ts", "import": "./src/desktopAppControl.ts" diff --git a/packages/shared/src/threadHandoff.test.ts b/packages/shared/src/threadHandoff.test.ts new file mode 100644 index 000000000000..6374a56ab918 --- /dev/null +++ b/packages/shared/src/threadHandoff.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + handoffThreadTitle, + parseHandoffCommand, + pickedProviderCommandForDraft, + resolveHandoffSend, + threadHandoffDisabledReason, +} from "./threadHandoff.ts"; + +describe("parseHandoffCommand", () => { + it("reads the focus after /handoff", () => { + expect(parseHandoffCommand("/handoff")).toEqual({ focus: "" }); + expect(parseHandoffCommand(" /HANDOFF fix the flaky test \n")).toEqual({ + focus: "fix the flaky test", + }); + expect(parseHandoffCommand("/handoff first\nsecond")).toEqual({ focus: "first\nsecond" }); + }); + + it("ignores other messages", () => { + expect(parseHandoffCommand("/handoffs")).toBeNull(); + expect(parseHandoffCommand("please /handoff")).toBeNull(); + expect(parseHandoffCommand("/side question")).toBeNull(); + }); +}); + +describe("handoffThreadTitle", () => { + it("marks the new thread as a handoff", () => { + expect(handoffThreadTitle("Fix login")).toBe("Fix login (handoff)"); + expect(handoffThreadTitle(" ")).toBe("Untitled thread (handoff)"); + }); +}); + +describe("threadHandoffDisabledReason", () => { + it("waits for the first finished reply", () => { + expect(threadHandoffDisabledReason([])).toBe("Hand off after the first reply finishes."); + expect(threadHandoffDisabledReason([{ status: "completed", checkpointId: null }])).toBe( + "Hand off after the first reply finishes.", + ); + expect(threadHandoffDisabledReason([{ status: "failed", checkpointId: null }])).toBe( + "Hand off after the first reply finishes.", + ); + }); + + it("waits for a running turn", () => { + for (const status of ["preparing", "starting", "running", "waiting"] as const) { + expect( + threadHandoffDisabledReason([ + { status: "completed", checkpointId: "checkpoint-1" as never }, + { status, checkpointId: null }, + ]), + ).toBe("Wait for the current turn to finish, or stop it, before handing off."); + } + }); + + it("allows a thread with a finished reply and nothing running", () => { + expect( + threadHandoffDisabledReason([ + { status: "completed", checkpointId: "checkpoint-1" as never }, + { status: "interrupted", checkpointId: null }, + { status: "queued", checkpointId: null }, + ]), + ).toBeNull(); + }); +}); + +describe("provider /handoff stays reachable", () => { + it("T3 handles a typed /handoff", () => { + expect(resolveHandoffSend({ text: "/handoff tests", pickedProviderCommand: null })).toEqual({ + focus: "tests", + }); + }); + + it("the provider's picked /handoff goes to the provider", () => { + expect( + resolveHandoffSend({ text: "/handoff tests", pickedProviderCommand: "handoff" }), + ).toBeNull(); + // Another picked command does not hide T3's handoff. + expect(resolveHandoffSend({ text: "/handoff", pickedProviderCommand: "review" })).toEqual({ + focus: "", + }); + }); + + it("forgets the pick once the draft no longer starts with it", () => { + const picked = { ownerKey: "thread-1", name: "handoff" }; + expect( + pickedProviderCommandForDraft({ picked, ownerKey: "thread-1", draft: "/handoff now" }), + ).toBe("handoff"); + expect(pickedProviderCommandForDraft({ picked, ownerKey: "thread-1", draft: "/handoff" })).toBe( + "handoff", + ); + expect( + pickedProviderCommandForDraft({ picked, ownerKey: "thread-1", draft: "/handoffs" }), + ).toBeNull(); + expect(pickedProviderCommandForDraft({ picked, ownerKey: "thread-1", draft: "" })).toBeNull(); + expect( + pickedProviderCommandForDraft({ picked, ownerKey: "thread-2", draft: "/handoff" }), + ).toBeNull(); + expect( + pickedProviderCommandForDraft({ picked: null, ownerKey: "thread-1", draft: "/handoff" }), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/threadHandoff.ts b/packages/shared/src/threadHandoff.ts new file mode 100644 index 000000000000..3c6e1f8112df --- /dev/null +++ b/packages/shared/src/threadHandoff.ts @@ -0,0 +1,76 @@ +import type { OrchestrationV2Run } from "@t3tools/contracts"; + +/** + * `/handoff [focus]` forks the thread at its latest finished reply into a new top-level thread and + * opens it, sending the focus text there as its first message. Web and mobile composers share + * this command; it is a T3 built-in, listed before any provider command with the same name. + */ +export const HANDOFF_COMMAND = { + name: "handoff", + description: "Continue in a new thread that keeps this conversation", +} as const; + +/** `/handoff [focus]` as the whole message. */ +export function parseHandoffCommand(text: string): { readonly focus: string } | null { + const match = /^\/handoff(?:\s+([\s\S]*))?$/i.exec(text.trim()); + return match ? { focus: match[1]?.trim() ?? "" } : null; +} + +export function handoffThreadTitle(sourceTitle: string): string { + const title = sourceTitle.trim(); + return `${title.length > 0 ? title : "Untitled thread"} (handoff)`; +} + +const ACTIVE_RUN_STATUSES: ReadonlySet = new Set([ + "preparing", + "starting", + "running", + "waiting", +]); + +/** + * Why the thread cannot be handed off yet, or null when it can. The new thread starts from the + * latest finished reply, so a handoff waits for the first one and for the running turn to end. + */ +export function threadHandoffDisabledReason( + runs: ReadonlyArray>, +): string | null { + if (runs.some((run) => ACTIVE_RUN_STATUSES.has(run.status))) { + return "Wait for the current turn to finish, or stop it, before handing off."; + } + if (!runs.some((run) => run.status === "completed" && run.checkpointId !== null)) { + return "Hand off after the first reply finishes."; + } + return null; +} + +/** + * The text `/handoff` sends on, or null when the message is not T3's handoff: either it is not a + * `/handoff` message, or the user picked the provider's own `/handoff` from the menu, which then + * goes to the provider as typed. + */ +export function resolveHandoffSend(input: { + readonly text: string; + readonly pickedProviderCommand: string | null; +}): { readonly focus: string } | null { + if (input.pickedProviderCommand?.toLowerCase() === HANDOFF_COMMAND.name) return null; + return parseHandoffCommand(input.text); +} + +/** + * The provider command the user picked from the menu, while the draft still starts with it. A + * provider command that shares a name with a T3 built-in reaches the provider only this way. + */ +export function pickedProviderCommandForDraft(input: { + readonly picked: { readonly ownerKey: string | null; readonly name: string } | null; + readonly ownerKey: string | null; + readonly draft: string; +}): string | null { + const { picked } = input; + if (picked === null || picked.ownerKey !== input.ownerKey) return null; + const prefix = `/${picked.name.toLowerCase()}`; + const draft = input.draft.trimStart().toLowerCase(); + return draft === prefix || draft.startsWith(`${prefix} `) || draft.startsWith(`${prefix}\n`) + ? picked.name + : null; +} From 353ee0272f95a463b0196a4545ef7c54aa0e5fa2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:07:49 -0600 Subject: [PATCH 2/5] feat(mobile): share with guest and hand off from the thread menus Android's header overflow, the iOS header Share menu and the command palette offer Hand off to new thread, Share with guest (New guest link while one is active) and Stop sharing. Creating a link copies it and opens the share sheet; server refusals name where to turn links on. --- apps/mobile/src/components/ui/AppBar.types.ts | 4 + .../src/features/keyboard/CommandPalette.tsx | 26 ++ .../features/threads/ThreadGuestShareBar.tsx | 4 +- .../src/features/threads/ThreadHeader.tsx | 16 ++ .../features/threads/ThreadRouteScreen.tsx | 7 + .../features/threads/threadShareMenu.test.ts | 94 +++++++ .../src/features/threads/threadShareMenu.ts | 84 ++++++ .../threads/useThreadHeaderOptions.tsx | 48 +++- .../features/threads/useThreadShareActions.ts | 260 ++++++++++++++++++ 9 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadShareMenu.test.ts create mode 100644 apps/mobile/src/features/threads/threadShareMenu.ts create mode 100644 apps/mobile/src/features/threads/useThreadShareActions.ts diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index e3db494219d7..9ffb3bed0b08 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -11,6 +11,10 @@ export interface AppBarAction { readonly loading?: boolean; readonly selected?: boolean; readonly tintColor?: ColorValue; + /** Android: listed only in the overflow menu, with this subtitle. iOS ignores them. */ + readonly overflowOnly?: boolean; + readonly subtitle?: string; + readonly destructive?: boolean; } export type AppBarMenuItem = diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index e9e9e50d4527..bb07c024bb2c 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -73,6 +73,8 @@ import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useThreadShareActions } from "../threads/useThreadShareActions"; +import type { ThreadShareActionId } from "../threads/threadShareMenu"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", @@ -98,6 +100,15 @@ const ACTION_ICONS: Record = { terminal: "terminal", review: PullRequestGlyph.pullRequest, copyThreadReference: "link", + "share:handoff": "arrow.triangle.branch", + "share:share-guest": "person.badge.plus", + "share:stop-guest-share": "person.fill.xmark", +}; + +const SHARE_ACTION_SEARCH_TERMS: Record> = { + handoff: ["handoff", "fork", "new thread", "continue", "transfer"], + "share-guest": ["share", "guest", "link", "collaborate", "invite", "friend"], + "stop-guest-share": ["share", "guest", "link", "revoke", "unshare"], }; function itemIcon(item: CommandPaletteItem): AppSymbolName { @@ -229,6 +240,11 @@ export function CommandPalette(props: { const threads = useVisibleThreadShells(); const activeThreadRef = parseActiveThreadPath(props.pathname); const activeThread = useThreadShell(activeThreadRef); + const threadShareActions = useThreadShareActions( + activeThreadRef?.environmentId ?? null, + activeThreadRef?.threadId ?? null, + ); + const runShareAction = threadShareActions.run; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const environments = useWorkspaceEnvironments(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -399,6 +415,14 @@ export function CommandPalette(props: { searchTerms, run: () => runCommand(command), })), + ...threadShareActions.actions.map((action) => ({ + key: `share:${action.id}`, + kind: "action" as const, + title: action.title, + ...(action.subtitle === undefined ? {} : { detail: action.subtitle }), + searchTerms: SHARE_ACTION_SEARCH_TERMS[action.id], + run: () => runShareAction(action.id), + })), ); } const openProjects = projects.filter((project) => project.archivedAt == null); @@ -464,6 +488,8 @@ export function CommandPalette(props: { selectTab, activeThread, activeThreadRef, + threadShareActions.actions, + runShareAction, environments.length, canTriage, navigation, diff --git a/apps/mobile/src/features/threads/ThreadGuestShareBar.tsx b/apps/mobile/src/features/threads/ThreadGuestShareBar.tsx index 23d8711882af..eea3d3c86650 100644 --- a/apps/mobile/src/features/threads/ThreadGuestShareBar.tsx +++ b/apps/mobile/src/features/threads/ThreadGuestShareBar.tsx @@ -54,8 +54,8 @@ function alertOnFailure(failureTitle: string, result: AtomCommandResult>(() => { const actions: AppBarAction[] = []; @@ -61,6 +62,19 @@ export function ThreadHeader( onPress: onMergeBack, }); } + if (onShareAction) { + for (const action of shareActions ?? []) { + actions.push({ + accessibilityLabel: action.title, + icon: action.icon, + onPress: () => onShareAction(action.id), + overflowOnly: true, + ...(action.subtitle === undefined ? {} : { subtitle: action.subtitle }), + ...(action.disabled ? { disabled: true } : {}), + ...(action.destructive ? { destructive: true } : {}), + }); + } + } return actions; }, [ props.inspectorMode, @@ -74,6 +88,8 @@ export function ThreadHeader( props.hasThreadCwd, props.hasWorkspaceRoot, props.gitControls.canOpenTerminal, + shareActions, + onShareAction, ]); return ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index a07aedfabf7e..d5f908ff582d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -81,6 +81,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; +import { useThreadShareActions } from "./useThreadShareActions"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; @@ -384,6 +385,10 @@ function ThreadRouteContent( const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); + const shareActions = useThreadShareActions( + selectedThread?.environmentId ?? null, + selectedThread?.id ?? null, + ); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, { label: "resume queue", @@ -1023,6 +1028,8 @@ function ThreadRouteContent( onOpenGitInspector={handleOpenGitInspector} onOpenFilesInspector={handleOpenFilesInspector} onReturnToThread={props.onReturnToThread} + shareActions={shareActions.actions} + onShareAction={shareActions.run} /> ) } diff --git a/apps/mobile/src/features/threads/threadShareMenu.test.ts b/apps/mobile/src/features/threads/threadShareMenu.test.ts new file mode 100644 index 000000000000..d5d74669fbf9 --- /dev/null +++ b/apps/mobile/src/features/threads/threadShareMenu.test.ts @@ -0,0 +1,94 @@ +import { GuestShareCreateError } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildThreadShareActions, guestShareCreateFailureMessage } from "./threadShareMenu"; + +describe("buildThreadShareActions", () => { + it("offers handoff and a guest link on an idle thread", () => { + const actions = buildThreadShareActions({ + canOperateThread: true, + handoffDisabledReason: null, + canCreateGuestShare: true, + hasGuestShare: false, + }); + expect(actions.map((action) => [action.id, action.title, action.disabled ?? false])).toEqual([ + ["handoff", "Hand off to new thread", false], + ["share-guest", "Share with guest", false], + ]); + }); + + it("shows why handoff is not available yet", () => { + const [handoff] = buildThreadShareActions({ + canOperateThread: true, + handoffDisabledReason: "Hand off after the first reply finishes.", + canCreateGuestShare: false, + hasGuestShare: false, + }); + expect(handoff).toMatchObject({ + id: "handoff", + disabled: true, + subtitle: "Hand off after the first reply finishes.", + }); + }); + + it("offers a replacement link and the way out while a share is active", () => { + const actions = buildThreadShareActions({ + canOperateThread: true, + handoffDisabledReason: null, + canCreateGuestShare: true, + hasGuestShare: true, + }); + expect(actions.map((action) => action.title)).toEqual([ + "Hand off to new thread", + "New guest link", + "Stop sharing", + ]); + expect(actions.at(-1)?.destructive).toBe(true); + }); + + it("keeps Stop sharing without permission to create links", () => { + const actions = buildThreadShareActions({ + canOperateThread: true, + handoffDisabledReason: null, + canCreateGuestShare: false, + hasGuestShare: true, + }); + expect(actions.map((action) => action.id)).toEqual(["handoff", "stop-guest-share"]); + }); + + it("offers nothing to a viewer who cannot operate the thread", () => { + expect( + buildThreadShareActions({ + canOperateThread: false, + handoffDisabledReason: null, + canCreateGuestShare: true, + hasGuestShare: true, + }), + ).toEqual([]); + }); +}); + +describe("guestShareCreateFailureMessage", () => { + it("tells the user where to turn guest links on", () => { + expect(guestShareCreateFailureMessage(new GuestShareCreateError({ reason: "disabled" }))).toBe( + "Guest links are off on this machine. Turn on Settings > Labs > Guest links in T3 Code on the computer running it, then try again.", + ); + // RPC failures can arrive as plain tagged objects. + expect( + guestShareCreateFailureMessage({ + _tag: "GuestShareCreateError", + reason: "connect_unavailable", + }), + ).toBe( + "Guest links need T3 Connect. Start T3 Connect on the computer running T3 Code, then try again.", + ); + }); + + it("falls back to the error's own message", () => { + expect(guestShareCreateFailureMessage(new Error("Socket closed"))).toBe("Socket closed"); + expect(guestShareCreateFailureMessage(undefined)).toBe("Could not create a guest link."); + expect( + guestShareCreateFailureMessage({ _tag: "GuestShareCreateError", reason: "unknown" }), + ).toBe("Could not create a guest link."); + }); +}); diff --git a/apps/mobile/src/features/threads/threadShareMenu.ts b/apps/mobile/src/features/threads/threadShareMenu.ts new file mode 100644 index 000000000000..2fcb02525eeb --- /dev/null +++ b/apps/mobile/src/features/threads/threadShareMenu.ts @@ -0,0 +1,84 @@ +import type { AppSymbolName } from "../../components/ui/AppSymbol"; + +/** + * Thread-level share and handoff actions, in the order every mobile entry point lists them: the + * Android header overflow, the iOS header menu, and the command palette. + */ +export type ThreadShareActionId = "handoff" | "share-guest" | "stop-guest-share"; + +export interface ThreadShareAction { + readonly id: ThreadShareActionId; + readonly title: string; + readonly subtitle?: string; + readonly icon: AppSymbolName; + readonly disabled?: boolean; + readonly destructive?: boolean; +} + +export function buildThreadShareActions(input: { + readonly canOperateThread: boolean; + /** Null when the thread can be handed off now. */ + readonly handoffDisabledReason: string | null; + readonly canCreateGuestShare: boolean; + readonly hasGuestShare: boolean; +}): ThreadShareAction[] { + if (!input.canOperateThread) return []; + const actions: ThreadShareAction[] = [ + { + id: "handoff", + title: "Hand off to new thread", + subtitle: input.handoffDisabledReason ?? "Continue in a new thread with this conversation", + icon: "arrow.triangle.branch", + ...(input.handoffDisabledReason === null ? {} : { disabled: true }), + }, + ]; + if (input.canCreateGuestShare) { + actions.push({ + id: "share-guest", + // A thread has one guest link; a new one ends the current one. + title: input.hasGuestShare ? "New guest link" : "Share with guest", + subtitle: input.hasGuestShare + ? "Ends the current guest link" + : "One guest can watch and suggest messages", + icon: "person.badge.plus", + }); + } + if (input.hasGuestShare) { + actions.push({ + id: "stop-guest-share", + title: "Stop sharing", + subtitle: "The guest loses access", + icon: "person.fill.xmark", + destructive: true, + }); + } + return actions; +} + +const GUEST_SHARE_CREATE_FAILURES = { + disabled: + "Guest links are off on this machine. Turn on Settings > Labs > Guest links in T3 Code on the computer running it, then try again.", + connect_unavailable: + "Guest links need T3 Connect. Start T3 Connect on the computer running T3 Code, then try again.", + thread_unavailable: "This thread cannot be shared.", + read_failed: "Failed to read the shared thread.", + changed: "The guest link changed while it was being created. Try again.", +} as const; + +/** The message for a failed guest link, by the server's `GuestShareCreateError` reason. */ +export function guestShareCreateFailureMessage(error: unknown): string { + if ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "GuestShareCreateError" && + "reason" in error && + typeof error.reason === "string" && + error.reason in GUEST_SHARE_CREATE_FAILURES + ) { + return GUEST_SHARE_CREATE_FAILURES[error.reason as keyof typeof GUEST_SHARE_CREATE_FAILURES]; + } + return error instanceof Error && error.message.length > 0 + ? error.message + : "Could not create a guest link."; +} diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx index 8363af1a3e19..029cf43ff0cf 100644 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx @@ -12,9 +12,42 @@ import { useThreadGitCenterHeaderItems, useThreadGitRightHeaderItems, } from "./ThreadGitControls"; +import type { ThreadShareAction, ThreadShareActionId } from "./threadShareMenu"; type NativeHeaderItems = ReadonlyArray>; +const NO_SHARE_ACTIONS: ReadonlyArray = []; + +/** The iOS header menu for handoff and guest links; Android lists them in its overflow menu. */ +function threadShareHeaderItems( + actions: ReadonlyArray, + onAction: (id: ThreadShareActionId) => void, +): NativeHeaderItems { + if (actions.length === 0) return []; + return [ + { + accessibilityLabel: "Share and hand off", + icon: { name: "square.and.arrow.up", type: "sfSymbol" as const }, + identifier: "thread-right-share", + label: "Share", + menu: { + items: actions.map((action) => ({ + ...(action.subtitle === undefined ? {} : { description: action.subtitle }), + disabled: action.disabled === true, + icon: { name: action.icon, type: "sfSymbol" as const }, + label: action.title, + onPress: () => onAction(action.id), + type: "action" as const, + })), + title: "Share", + }, + sharesBackground: true, + type: "menu" as const, + variant: "plain" as const, + }, + ]; +} + export function useThreadHeaderOptions(props: { readonly title: string; readonly subtitle: string; @@ -23,6 +56,8 @@ export function useThreadHeaderOptions(props: { readonly gitControls: Parameters[0]; readonly onReturnToThread?: () => void; readonly draft?: boolean; + readonly shareActions?: ReadonlyArray; + readonly onShareAction?: (id: ThreadShareActionId) => void; }) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const deviceType = useDeviceType(); @@ -32,6 +67,10 @@ export function useThreadHeaderOptions(props: { const sidebarHeaderItem = useWorkspaceSidebarHeaderItem(); const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); + const shareHeaderItems = threadShareHeaderItems( + props.shareActions ?? NO_SHARE_ACTIONS, + props.onShareAction ?? (() => {}), + ); const splitLeftHeaderItems: NativeHeaderItems = [ ...(!usesDuoHeader ? [ @@ -88,8 +127,12 @@ export function useThreadHeaderOptions(props: { ? undefined : () => compactHomeHeaderItems, // The workspace navigator adds compose to the Duo detail column's bar. - unstable_headerRightItems: () => - usesDuoHeader || layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + unstable_headerRightItems: () => [ + ...(usesDuoHeader || layout.usesSplitView + ? threadCenterHeaderItems + : compactRightHeaderItems), + ...shareHeaderItems, + ], unstable_headerToolbarItems: () => [], unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, contentStyle: undefined, @@ -102,6 +145,7 @@ export function useThreadHeaderOptions(props: { splitLeftHeaderItems, threadCenterHeaderItems, compactRightHeaderItems, + props.shareActions, usesDuoHeader, environmentId, threadId, diff --git a/apps/mobile/src/features/threads/useThreadShareActions.ts b/apps/mobile/src/features/threads/useThreadShareActions.ts new file mode 100644 index 000000000000..ba98c4498726 --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadShareActions.ts @@ -0,0 +1,260 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + MessageId, + type ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { handoffThreadTitle, threadHandoffDisabledReason } from "@t3tools/shared/threadHandoff"; +import { Atom } from "effect/reactivity"; +import * as Clipboard from "expo-clipboard"; +import { useMemo, useRef, useState } from "react"; +import { Share } from "react-native"; + +import * as Dialog from "../../components/ui/Dialog"; +import { uuidv4 } from "../../lib/uuid"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentScope } from "../../state/session"; +import { + environmentThreadDetails, + environmentThreadShells, + threadEnvironment, +} from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useThreadGuestShare } from "./ThreadGuestShareBar"; +import { waitForThreadShellReady } from "./threadForkNavigation"; +import { + buildThreadShareActions, + guestShareCreateFailureMessage, + type ThreadShareActionId, +} from "./threadShareMenu"; + +const NO_THREAD_REASON = "Open a thread to hand it off."; +const NO_THREAD_REASON_ATOM = Atom.make(NO_THREAD_REASON); + +function handoffReasonAtom(ref: ScopedThreadRef) { + return Atom.make((get): string | null => { + const detail = get(environmentThreadDetails.threadAtom(ref)); + return detail === null || detail === undefined + ? "The thread is still loading." + : threadHandoffDisabledReason(detail.projection.runs); + }); +} + +function failureMessage( + result: Extract, { readonly _tag: "Failure" }>, + fallback: string, +) { + const error = squashAtomCommandFailure(result); + return error instanceof Error && error.message.length > 0 ? error.message : fallback; +} + +/** + * The thread's share and handoff actions for the header menus and the command palette, and + * `handoff`, which `/handoff` in the composer also runs. `threadRef` is null on screens without a + * server thread, where no actions are offered. + */ +export function useThreadShareActions( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +) { + const threadRef = useMemo( + () => (environmentId && threadId ? scopeThreadRef(environmentId, threadId) : null), + [environmentId, threadId], + ); + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canCreateGuestShare = useAtomValue( + serverEnvironment.createGuestShare.permissionAtom(environmentId), + ); + const reasonAtom = useMemo( + () => (threadRef === null ? NO_THREAD_REASON_ATOM : handoffReasonAtom(threadRef)), + [threadRef], + ); + const handoffDisabledReason = useAtomValue(reasonAtom); + const share = useThreadGuestShare(threadRef); + const selectDetail = useSelectWorkspaceDetail(); + const forkForHandoff = useAtomCommand(threadEnvironment.forkForHandoff, { + label: "hand off thread", + reportFailure: false, + }); + const startTurn = useAtomCommand(threadEnvironment.startTurn, { + label: "send handoff message", + reportFailure: false, + }); + const createGuestShare = useAtomCommand(serverEnvironment.createGuestShare, { + label: "create guest link", + reportFailure: false, + }); + const closeGuestShare = useAtomCommand(threadEnvironment.closeGuestShare, "stop guest sharing"); + const pendingRef = useRef(false); + const [busy, setBusy] = useState(false); + + const runExclusive = async (work: () => Promise, idle: A): Promise => { + if (pendingRef.current) return idle; + pendingRef.current = true; + setBusy(true); + try { + return await work(); + } finally { + pendingRef.current = false; + setBusy(false); + } + }; + + /** Forks into a new thread, opens it, and sends `focus` there first. False when nothing opened. */ + const handoff = (focus?: string): Promise => + runExclusive(async () => { + if (threadRef === null) return false; + const reason = appAtomRegistry.get(reasonAtom); + if (reason !== null) { + Dialog.alert("Cannot hand off yet", reason); + return false; + } + const source = appAtomRegistry.get(environmentThreadShells.threadShellAtom(threadRef)); + const targetThreadId = ThreadId.make(uuidv4()); + const targetRef = scopeThreadRef(threadRef.environmentId, targetThreadId); + const fork = await forkForHandoff({ + environmentId: threadRef.environmentId, + input: { + sourceThreadId: threadRef.threadId, + targetThreadId, + title: handoffThreadTitle(source?.title ?? ""), + creationSource: "mobile", + }, + }); + if (fork._tag !== "Success") { + if (!isAtomCommandInterrupted(fork)) { + Dialog.alert("Could not hand off", failureMessage(fork, "Try again.")); + } + return false; + } + const targetShellAtom = environmentThreadShells.threadShellAtom(targetRef); + const ready = await waitForThreadShellReady({ + read: () => appAtomRegistry.get(targetShellAtom) !== null, + }); + const target = appAtomRegistry.get(targetShellAtom); + if (!ready || target === null) { + Dialog.alert( + "Handoff created", + "Its thread data did not reach this client. Reconnect and try opening it from the thread list.", + ); + return false; + } + const text = focus?.trim(); + if (text) { + const turn = await startTurn({ + environmentId: threadRef.environmentId, + input: { + threadId: targetThreadId, + message: { messageId: MessageId.make(uuidv4()), role: "user", text, attachments: [] }, + runtimeMode: target.runtimeMode, + interactionMode: target.interactionMode, + createdAt: new Date().toISOString(), + }, + }); + if (turn._tag !== "Success" && !isAtomCommandInterrupted(turn)) { + Dialog.alert( + "Handoff message not sent", + `${failureMessage(turn, "Try again.")} The new thread is open; send it there.`, + ); + } + } + selectDetail({ + name: "Thread", + params: { environmentId: threadRef.environmentId, threadId: targetThreadId }, + }); + return true; + }, false); + + const createAndShareLink = () => + runExclusive(async () => { + if (threadRef === null) return; + const result = await createGuestShare({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) { + Dialog.alert( + "Could not create a guest link", + guestShareCreateFailureMessage(squashAtomCommandFailure(result)), + ); + } + return; + } + const { url } = result.value; + // The link is shown only now; copy it so it survives a dismissed share sheet. + await Clipboard.setStringAsync(url).catch(() => undefined); + await Share.share({ message: url, url, title: "T3 Code guest link" }).catch(() => { + Dialog.alert("Guest link copied", url); + }); + }, undefined); + + const shareWithGuest = () => { + if (share === null) { + void createAndShareLink(); + return; + } + Dialog.confirm({ + title: "Make a new guest link?", + message: "The current link stops working and its guest loses access.", + confirmText: "New link", + onConfirm: () => void createAndShareLink(), + }); + }; + + const stopSharing = () => { + if (threadRef === null) return; + Dialog.confirm({ + title: "Stop sharing?", + message: "The guest loses access and the link stops working.", + confirmText: "Stop sharing", + destructive: true, + onConfirm: () => + void runExclusive(async () => { + await closeGuestShare({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + }, undefined), + }); + }; + + const hasGuestShare = share !== null; + const actions = useMemo( + () => + threadRef === null + ? [] + : buildThreadShareActions({ + canOperateThread, + handoffDisabledReason: busy ? "Working…" : handoffDisabledReason, + canCreateGuestShare, + hasGuestShare, + }), + [threadRef, canOperateThread, busy, handoffDisabledReason, canCreateGuestShare, hasGuestShare], + ); + + const run = (id: ThreadShareActionId) => { + if (id === "handoff") void handoff(); + else if (id === "share-guest") shareWithGuest(); + else stopSharing(); + }; + + return { + actions, + run, + handoff, + handoffDisabledReason: canOperateThread + ? handoffDisabledReason + : "This connection cannot start threads.", + }; +} From 96ef9399fd49582692e1a1f0019723006e3857ca Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:07:49 -0600 Subject: [PATCH 3/5] feat(mobile): /handoff [focus] in the composer T3's /handoff is listed before a provider command of the same name; picking the provider's from the menu still sends it to the provider. --- .../src/features/threads/ThreadComposer.tsx | 30 +++++ .../threads/use-composer-command-menu.test.ts | 110 ++++++++++++++++++ .../threads/use-composer-command-menu.ts | 43 ++++++- 3 files changed, 182 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index ea2928f53137..3f2b8ea4ed0f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -102,6 +102,8 @@ import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; +import { useThreadShareActions } from "./useThreadShareActions"; +import { resolveHandoffSend } from "@t3tools/shared/threadHandoff"; import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems"; import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; import { @@ -535,6 +537,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps }; const mentionableEnvironments = useMentionableEnvironments(); + const threadShareActions = useThreadShareActions( + props.environmentId, + props.isDraft ? null : props.selectedThread.id, + ); const composerMenu = useComposerCommandMenu({ draftMessage: draftMessage, ownerKey: composerDraftKey, @@ -558,6 +564,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ? undefined : props.onUpdateInteractionMode, offersUsageLimits: usageLimitsOffered, + ...(props.isDraft ? {} : { handoffDisabledReason: threadShareActions.handoffDisabledReason }), onUsageLimits: usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined, }); @@ -672,6 +679,29 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps if (openUsageLimits()) onChangeDraftMessage(""); return; } + const handoffCommand = + props.isDraft || queuedEdit !== null + ? null + : resolveHandoffSend({ + text: draftMessage, + pickedProviderCommand: composerMenu.pickedProviderCommand, + }); + if (handoffCommand) { + const draftAtHandoff = getComposerDraftSnapshot(composerDraftKey); + if (draftAttachments.length > 0 || (draftAtHandoff.context?.records.length ?? 0) > 0) { + Dialog.alert( + "Handoff takes text only", + "Remove the attachments and context, then send it again.", + ); + return; + } + const started = await threadShareActions.handoff(handoffCommand.focus); + // Clear only what was sent; the user may have edited the draft meanwhile. + if (started && getComposerDraftSnapshot(composerDraftKey).text === draftAtHandoff.text) { + onChangeDraftMessage(""); + } + return; + } const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index c0044a74f794..b897d92e15ae 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -126,3 +126,113 @@ describe("mobile slash commands", () => { ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); }); }); + +describe("mobile /handoff", () => { + const codex = (slashCommands: ReadonlyArray<{ name: string; description?: string }>) => ({ + driver: ProviderDriverKind.make("codex"), + slashCommands, + }); + + it("offers /handoff at the message start of a thread and inserts it for a focus", () => { + const items = buildComposerSlashCommandItems({ + query: "hand", + atMessageStart: true, + hasThread: true, + handoffDisabledReason: null, + allowInteractionMode: true, + selectedProviderStatus: codex([]), + }); + expect(items.map((item) => [item.id, item.label, item.description])).toEqual([ + ["cmd:handoff", "/handoff", "Continue in a new thread that keeps this conversation"], + ]); + const [item] = items; + if (!item) throw new Error("Expected /handoff"); + expect( + resolveComposerCommandSelection({ + draftMessage: "/hand", + trigger: { rangeStart: 0, rangeEnd: 5 }, + item, + allowInteractionMode: true, + }), + ).toEqual({ text: "/handoff ", cursor: 9, interactionMode: null }); + }); + + it("shows why /handoff cannot run yet", () => { + const [item] = buildComposerSlashCommandItems({ + query: "handoff", + atMessageStart: true, + hasThread: true, + handoffDisabledReason: "Hand off after the first reply finishes.", + allowInteractionMode: true, + selectedProviderStatus: codex([]), + }); + expect(item?.description).toBe("Hand off after the first reply finishes."); + }); + + it("hides /handoff in drafts, mid-message, and where it is not offered", () => { + const base = { + query: "handoff", + allowInteractionMode: true, + selectedProviderStatus: codex([]), + }; + expect( + buildComposerSlashCommandItems({ + ...base, + atMessageStart: true, + hasThread: false, + handoffDisabledReason: null, + }), + ).toEqual([]); + expect( + buildComposerSlashCommandItems({ + ...base, + atMessageStart: false, + hasThread: true, + handoffDisabledReason: null, + }), + ).toEqual([]); + expect( + buildComposerSlashCommandItems({ ...base, atMessageStart: true, hasThread: true }), + ).toEqual([]); + }); + + it("lists T3's /handoff first and keeps the provider's /handoff reachable", () => { + const items = buildComposerSlashCommandItems({ + query: "handoff", + atMessageStart: true, + hasThread: true, + handoffDisabledReason: null, + allowInteractionMode: true, + selectedProviderStatus: codex([{ name: "handoff", description: "Write a handoff note" }]), + }); + expect(items.map((item) => [item.id, item.label, item.description])).toEqual([ + ["cmd:handoff", "/handoff", "Continue in a new thread that keeps this conversation"], + ["pcmd:handoff", "/handoff", "Write a handoff note"], + ]); + const providerItem = items[1]; + if (!providerItem) throw new Error("Expected the provider's /handoff"); + expect( + resolveComposerCommandSelection({ + draftMessage: "/handoff", + trigger: { rangeStart: 0, rangeEnd: 8 }, + item: providerItem, + allowInteractionMode: true, + }), + ).toEqual({ text: "/handoff ", cursor: 9, interactionMode: null }); + }); + + it("names the provider's /handoff when it has no description", () => { + const items = buildComposerSlashCommandItems({ + query: "handoff", + atMessageStart: true, + hasThread: true, + handoffDisabledReason: null, + allowInteractionMode: true, + selectedProviderStatus: codex([{ name: "handoff" }]), + }); + expect(items[1]).toMatchObject({ + id: "pcmd:handoff", + description: "The provider's own command", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 72987599f48f..6c1651488606 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -30,6 +30,7 @@ import { useComposerDraftSelection, } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; +import { HANDOFF_COMMAND, pickedProviderCommandForDraft } from "@t3tools/shared/threadHandoff"; import { detectComposerTrigger, replaceTextRange, @@ -104,6 +105,8 @@ export function buildComposerSlashCommandItems(input: { readonly hasThread: boolean; readonly hasCompactableConversation?: boolean; readonly offersUsageLimits?: boolean; + /** Offers `/handoff`; null when it can run now, otherwise why it cannot. Absent hides it. */ + readonly handoffDisabledReason?: string | null; readonly allowInteractionMode: boolean; readonly selectedProviderStatus: Pick< ServerProvider, @@ -143,6 +146,20 @@ export function buildComposerSlashCommandItems(input: { // Providers expand commands only at the start of a message. T3 commands // change local state and do not have this restriction. if (!input.atMessageStart) return items; + // `/handoff [focus]` is the whole message, so it too starts one. + const offersHandoff = + input.hasThread && + input.handoffDisabledReason !== undefined && + HANDOFF_COMMAND.name.includes(query); + if (offersHandoff) { + items.push({ + id: `cmd:${HANDOFF_COMMAND.name}`, + type: "slash-command", + command: HANDOFF_COMMAND.name, + label: `/${HANDOFF_COMMAND.name}`, + description: input.handoffDisabledReason ?? HANDOFF_COMMAND.description, + }); + } for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; @@ -156,12 +173,14 @@ export function buildComposerSlashCommandItems(input: { ) { continue; } + // Listed after T3's own command of the same name, and still sent to the provider when picked. + const shadowsBuiltIn = offersHandoff && command.name.toLowerCase() === HANDOFF_COMMAND.name; items.push({ id: `pcmd:${command.name}`, type: "provider-slash-command", command, label: `/${command.name}`, - description: command.description ?? "", + description: command.description ?? (shadowsBuiltIn ? "The provider's own command" : ""), }); } return items; @@ -221,6 +240,7 @@ export function useComposerCommandMenu({ hasThread, hasCompactableConversation, offersUsageLimits = false, + handoffDisabledReason, enabled = true, onChangeDraftMessage, onUpdateInteractionMode, @@ -242,6 +262,8 @@ export function useComposerCommandMenu({ readonly hasThread: boolean; readonly hasCompactableConversation: boolean; readonly offersUsageLimits?: boolean; + /** Offers `/handoff`; see `buildComposerSlashCommandItems`. */ + readonly handoffDisabledReason?: string | null; readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; @@ -251,6 +273,11 @@ export function useComposerCommandMenu({ ownerKey, ...composerSelectionAtEnd(draftMessage), })); + // A provider command picked from the menu that shares its name with a T3 built-in. + const [pickedProviderCommand, setPickedProviderCommand] = useState<{ + readonly ownerKey: string | null; + readonly name: string; + } | null>(null); // Thread mentions rank by recency as of when the composer mounted. const [now] = useState(() => Date.now()); const insertedSelection = useComposerDraftSelection(ownerKey, draftMessage); @@ -349,6 +376,7 @@ export function useComposerCommandMenu({ hasThread, hasCompactableConversation, offersUsageLimits, + ...(handoffDisabledReason === undefined ? {} : { handoffDisabledReason }), allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus: selectedProviderStatus ? { @@ -602,6 +630,13 @@ export function useComposerCommandMenu({ onUpdateInteractionMode !== undefined && selectedProviderStatus?.showInteractionModeToggle !== false, }); + // Picking the provider's `/handoff` sends it to the provider; T3's own clears that. + if (item.type === "provider-slash-command" || item.type === "slash-command") { + const name = item.type === "slash-command" ? null : item.command.name; + setPickedProviderCommand( + name !== null && name.toLowerCase() === HANDOFF_COMMAND.name ? { ownerKey, name } : null, + ); + } setSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); if (result.interactionMode !== null) { @@ -624,5 +659,11 @@ export function useComposerCommandMenu({ : pullRequestSearch.error : null, onSelect, + /** The provider command picked over a T3 built-in, while the draft still starts with it. */ + pickedProviderCommand: pickedProviderCommandForDraft({ + picked: pickedProviderCommand, + ownerKey, + draft: draftMessage, + }), }; } From 0cc4192f242a1f607702eff8e75ec5c48b6befb5 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:07:49 -0600 Subject: [PATCH 4/5] feat(web): /handoff [focus] in the composer Same rules as mobile: a whole-message command that forks at the latest finished reply, opens the new thread and sends the focus there, with a provider /handoff kept reachable from the menu. --- apps/web/src/components/ChatView.tsx | 22 +++++ apps/web/src/components/chat/ChatComposer.tsx | 27 +++++- .../chat/composerSlashCommandSearch.test.ts | 81 ++++++++++++++++ .../chat/composerSlashCommandSearch.ts | 45 ++++++++- .../components/chat/useComposerMenuItems.ts | 18 +++- .../src/components/chat/useComposerSend.ts | 53 ++++++++++ .../src/components/chat/useThreadHandoff.ts | 97 +++++++++++++++++++ apps/web/src/composer-logic.ts | 4 +- 8 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/chat/composerSlashCommandSearch.test.ts create mode 100644 apps/web/src/components/chat/useThreadHandoff.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a1c1c7bc91a5..1ecdfc4c4a0d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -276,6 +276,8 @@ import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { useSideChat } from "./chat/useSideChat"; +import { useThreadHandoff } from "./chat/useThreadHandoff"; +import { threadHandoffDisabledReason } from "@t3tools/shared/threadHandoff"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; @@ -2790,6 +2792,22 @@ export default function ChatView(props: ChatViewProps) { : null; const openSideChatStable = useStableCallback(() => void sideChat.open()); const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); + // `/handoff` forks into a new listed thread, so a subagent or side chat cannot start one. + const handoff = useThreadHandoff({ + threadRef: + !embedded && + canOperateThread && + isServerThread && + activeThread?.lineage.sideChat !== true && + !isSubagentThread + ? activeThreadRef + : null, + onError: (message) => { + if (activeThread) setThreadError(activeThread.id, message); + }, + }); + const handoffDisabledReason = threadHandoffDisabledReason(serverProjection?.runs ?? []); + const sendHandoffStable = useStableCallback((focus: string) => handoff.open(focus)); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -2954,6 +2972,8 @@ export default function ChatView(props: ChatViewProps) { handleInteractionModeChange, onSideChat: sideChat.available ? sendSideChatStable : undefined, sideChatDisabledReason, + onHandoff: handoff.available ? sendHandoffStable : undefined, + handoffDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, }); @@ -3639,6 +3659,8 @@ export default function ChatView(props: ChatViewProps) { onToggleKeepFullHistory={toggleKeepFullHistoryStable} sideChatAvailable={sideChat.available} sideChatDisabledReason={sideChatDisabledReason} + handoffAvailable={handoff.available} + handoffDisabledReason={handoffDisabledReason} onUsageLimitsCommand={ usageLimitsOffered && usageLimitsKey !== null && diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 25b7f5ce6f87..ea9d762ee18d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -53,6 +53,7 @@ import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { splitTransferredFiles } from "./workspaceFileDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; +import { HANDOFF_COMMAND, pickedProviderCommandForDraft } from "@t3tools/shared/threadHandoff"; import { memo, use, @@ -339,6 +340,8 @@ export interface ChatComposerHandle { interactionMode: ProviderInteractionMode; interactionModeEnabled: boolean; shownPendingApprovalIds: ReadonlyArray; + /** A provider command picked over a T3 built-in, while the prompt still starts with it. */ + pickedProviderCommand: string | null; }; validateProviderInput: (providerInput: string) => boolean; setMultipleModelSelections: (selections: ReadonlyArray) => void; @@ -383,6 +386,9 @@ export interface ChatComposerProps { onUsageLimitsCommand?: (() => void) | undefined; sideChatAvailable?: boolean; sideChatDisabledReason?: string | null; + /** Offers `/handoff`; `handoffDisabledReason` says why it cannot run yet. */ + handoffAvailable?: boolean; + handoffDisabledReason?: string | null; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -606,6 +612,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); + // A provider command picked from the menu that shares its name with a T3 built-in. + const pickedProviderCommandRef = useRef<{ ownerKey: string | null; name: string } | null>(null); const { heldRequestIds, onDraftChange: onTypingGuardDraftChange, @@ -1151,6 +1159,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeUiEnabled, sideChatAvailable: props.sideChatAvailable === true, sideChatDisabledReason: props.sideChatDisabledReason ?? null, + handoffAvailable: props.handoffAvailable === true, + handoffDisabledReason: props.handoffDisabledReason ?? null, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -1880,8 +1890,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } - if (item.command === "side" || item.command === "btw") { - if (props.sideChatDisabledReason) return; + if (item.command === "side" || item.command === "btw" || item.command === "handoff") { + if (item.command === "handoff") { + if (props.handoffDisabledReason) return; + pickedProviderCommandRef.current = null; + } else if (props.sideChatDisabledReason) return; const applied = applyPromptReplacement( trigger.rangeStart, trigger.rangeEnd, @@ -1914,6 +1927,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } const replacement = `/${item.command.name} `; + // Picking the provider's `/handoff` sends it to the provider instead of T3's. + pickedProviderCommandRef.current = + item.command.name.toLowerCase() === HANDOFF_COMMAND.name + ? { ownerKey: composerDraftTargetKey, name: item.command.name } + : null; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, @@ -3016,6 +3034,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderModels, interactionMode, interactionModeEnabled: planModeUiEnabled, + pickedProviderCommand: pickedProviderCommandForDraft({ + picked: pickedProviderCommandRef.current, + ownerKey: composerDraftTargetKey, + draft: promptRef.current, + }), }), setMultipleModelSelections, validateProviderInput: (providerInput: string) => { diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts new file mode 100644 index 000000000000..b55aaba31272 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -0,0 +1,81 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { parseSideChatCommand } from "../../composer-logic"; +import { + describeProviderCommandsShadowingBuiltIns, + handoffSlashCommandItems, + searchSlashCommandItems, + slashCommandItemsForPromptPosition, +} from "./composerSlashCommandSearch"; + +const codex = ProviderDriverKind.make("codex"); + +function providerItem(name: string, description?: string) { + return { + id: `provider-slash-command:${codex}:${name}`, + type: "provider-slash-command" as const, + provider: codex, + command: description === undefined ? { name } : { name, description }, + label: `/${name}`, + description: description ?? "Run provider command", + }; +} + +describe("web /handoff", () => { + it("is offered with its description, or why it cannot run yet", () => { + expect(handoffSlashCommandItems({ available: false, disabledReason: null })).toEqual([]); + expect(handoffSlashCommandItems({ available: true, disabledReason: null })).toEqual([ + { + id: "slash:handoff", + type: "slash-command", + command: "handoff", + label: "/handoff", + description: "Continue in a new thread that keeps this conversation", + }, + ]); + expect( + handoffSlashCommandItems({ + available: true, + disabledReason: "Hand off after the first reply finishes.", + })[0]?.description, + ).toBe("Hand off after the first reply finishes."); + }); + + it("works only at the start of a message", () => { + const items = handoffSlashCommandItems({ available: true, disabledReason: null }); + expect(slashCommandItemsForPromptPosition(items, true)).toHaveLength(1); + expect(slashCommandItemsForPromptPosition(items, false)).toEqual([]); + }); + + it("lists T3's /handoff first and keeps the provider's /handoff reachable", () => { + const builtIns = handoffSlashCommandItems({ available: true, disabledReason: null }); + const providerItems = describeProviderCommandsShadowingBuiltIns( + [providerItem("handoff", "Write a handoff note"), providerItem("review")], + builtIns, + ); + const ranked = searchSlashCommandItems([...providerItems, ...builtIns], "handoff"); + expect(ranked.map((item) => [item.id, item.description])).toEqual([ + ["slash:handoff", "Continue in a new thread that keeps this conversation"], + ["provider-slash-command:codex:handoff", "Write a handoff note"], + ]); + }); + + it("names the provider's /handoff when it has no description", () => { + const builtIns = handoffSlashCommandItems({ available: true, disabledReason: null }); + const [handoff, review] = describeProviderCommandsShadowingBuiltIns( + [providerItem("handoff"), providerItem("review")], + builtIns, + ); + expect(handoff?.description).toBe("The provider's own command"); + expect(review?.description).toBe("Run provider command"); + // Without T3's /handoff on offer, the provider's keeps its own label. + expect( + describeProviderCommandsShadowingBuiltIns([providerItem("handoff")], [])[0]?.description, + ).toBe("Run provider command"); + }); + + it("is not a side chat", () => { + expect(parseSideChatCommand("/handoff tests")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 0c5c04e28cae..ab0e7779f880 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -3,6 +3,7 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { HANDOFF_COMMAND } from "@t3tools/shared/threadHandoff"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; import { scoreProviderSkill } from "../../providerSkillSearch"; @@ -12,7 +13,44 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; -/** Provider commands, `/side` and `/btw` work only at the message start. */ +/** T3's `/handoff`, described by why it cannot run yet when `disabledReason` is set. */ +export function handoffSlashCommandItems(input: { + readonly available: boolean; + readonly disabledReason: string | null; +}): Array> { + if (!input.available) return []; + return [ + { + id: `slash:${HANDOFF_COMMAND.name}`, + type: "slash-command", + command: HANDOFF_COMMAND.name, + label: `/${HANDOFF_COMMAND.name}`, + description: input.disabledReason ?? HANDOFF_COMMAND.description, + }, + ]; +} + +/** + * A provider command named like an offered T3 built-in stays listed after it, and says it is + * the provider's when it has no description of its own. + */ +export function describeProviderCommandsShadowingBuiltIns< + Item extends Extract, +>( + items: ReadonlyArray, + builtIns: ReadonlyArray>, +): Item[] { + const builtInNames = new Set(builtIns.map((item) => item.command.toLowerCase())); + return items.map((item) => + builtInNames.has(item.command.name.toLowerCase()) && + item.command.description === undefined && + item.command.input?.hint === undefined + ? { ...item, description: "The provider's own command" } + : item, + ); +} + +/** Provider commands, `/side`, `/btw` and `/handoff` work only at the message start. */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, isAtPromptStart: boolean, @@ -23,7 +61,10 @@ export function slashCommandItemsForPromptPosition( return items.filter( (item) => item.type === "skill" || - (item.type === "slash-command" && item.command !== "side" && item.command !== "btw"), + (item.type === "slash-command" && + item.command !== "side" && + item.command !== "btw" && + item.command !== "handoff"), ); } diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 5f778ac6cac1..eeb4d1e0da95 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -44,6 +44,8 @@ import { } from "../pullRequest/pullRequestList.logic"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; import { + describeProviderCommandsShadowingBuiltIns, + handoffSlashCommandItems, searchSlashCommandItems, slashCommandItemsForPromptPosition, } from "./composerSlashCommandSearch"; @@ -66,6 +68,8 @@ export function useComposerMenuItems({ planModeUiEnabled, sideChatAvailable, sideChatDisabledReason, + handoffAvailable = false, + handoffDisabledReason = null, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -85,6 +89,9 @@ export function useComposerMenuItems({ readonly planModeUiEnabled: boolean; readonly sideChatAvailable: boolean; readonly sideChatDisabledReason: string | null; + /** Offers `/handoff`, described by `handoffDisabledReason` when it cannot run yet. */ + readonly handoffAvailable?: boolean; + readonly handoffDisabledReason?: string | null; readonly compactSlashCommandAvailable: boolean; readonly selectedProvider: ProviderDriverKind; readonly selectedProviderSkills: ServerProvider["skills"]; @@ -260,6 +267,10 @@ export function useComposerMenuItems({ }, ] as const) : []), + ...handoffSlashCommandItems({ + available: handoffAvailable, + disabledReason: handoffDisabledReason, + }), ] satisfies ReadonlyArray>; const slashMenuSkills = getProviderSkillsForSlashMenu( selectedProviderSkills, @@ -288,8 +299,11 @@ export function useComposerMenuItems({ skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); - const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( - (item) => item.command.name !== "compact" || compactSlashCommandAvailable, + const visibleProviderSlashCommandItems = describeProviderCommandsShadowingBuiltIns( + providerSlashCommandItems.filter( + (item) => item.command.name !== "compact" || compactSlashCommandAvailable, + ), + builtInSlashCommandItems, ); const slashCommandItems = slashCommandItemsForPromptPosition( [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 98084e979544..6e8d6d7f57e5 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -57,6 +57,7 @@ import { parseSideChatCommand, parseStandaloneComposerSlashCommand, } from "../../composer-logic"; +import { resolveHandoffSend } from "@t3tools/shared/threadHandoff"; import { resolvePlanFollowUpSubmission } from "../../proposedPlan"; import { type ChatMessage } from "../../types"; import { recordPromptSent } from "../GitHubStarPrompt"; @@ -245,6 +246,10 @@ interface ComposerSendInput { readonly onSideChat?: ((prompt: string) => Promise) | undefined; /** Refuses `/side` with this reason and keeps the draft. */ readonly sideChatDisabledReason: string | null; + /** Resolves true once `/handoff` opened its new thread. */ + readonly onHandoff?: ((focus: string) => Promise) | undefined; + /** Refuses `/handoff` with this reason and keeps the draft. */ + readonly handoffDisabledReason?: string | null; readonly handleNewThreadInActiveProject: () => void; readonly persistThreadSettingsForNextTurn: (input: { threadId: ThreadId; @@ -332,6 +337,8 @@ export function useComposerSend(input: ComposerSendInput) { handleInteractionModeChange, onSideChat, sideChatDisabledReason, + onHandoff, + handoffDisabledReason = null, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, } = input; @@ -533,6 +540,52 @@ export function useComposerSend(input: ComposerSendInput) { } return; } + // `/handoff` text can still answer an open question; a provider `/handoff` picked from the + // menu goes to the provider as typed. + const handoffCommand = + sendCtx && !directAnnotation && !queuedRunEdit.isEditing + ? resolveHandoffSend({ + text: promptAtSend, + pickedProviderCommand: sendCtx.pickedProviderCommand, + }) + : null; + if (sendCtx && handoffCommand && onHandoff && !answeringQuestion) { + if (handoffDisabledReason !== null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Handoff not started", + description: handoffDisabledReason, + }), + ); + return; + } + if ( + sendCtx.images.length > 0 || + sendCtx.files.length > 0 || + filterTerminalContextsWithText(sendCtx.terminalContexts).length > 0 || + sendCtx.previewAnnotations.length > 0 || + sendCtx.reviewComments.length > 0 || + sendCtx.threadContexts.length > 0 || + sendCtx.environmentContexts.length > 0 + ) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Handoff takes text only", + description: "Remove the attachments and context, then send it again.", + }), + ); + return; + } + const started = await onHandoff(handoffCommand.focus); + if (started && promptRef.current === promptAtSend) { + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + composerRef.current?.resetCursorState(); + } + return; + } if (answeringQuestion) { if (directAnnotation) { notifyDirectAnnotationAttached(); diff --git a/apps/web/src/components/chat/useThreadHandoff.ts b/apps/web/src/components/chat/useThreadHandoff.ts new file mode 100644 index 000000000000..b26328ffa1b7 --- /dev/null +++ b/apps/web/src/components/chat/useThreadHandoff.ts @@ -0,0 +1,97 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { handoffThreadTitle } from "@t3tools/shared/threadHandoff"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef } from "react"; + +import { newMessageId, newThreadId } from "~/lib/utils"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import { readThreadShell, waitForThreadShell } from "../../state/entities"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import { buildThreadRouteParams } from "../../threadRoutes"; + +/** + * `/handoff [focus]`: forks `threadRef` at its latest finished reply into a new listed thread, + * opens it, and sends `focus` there as its first message. `open` resolves false when no new + * thread opened, or another handoff is still starting. Offers none when `threadRef` is null. + */ +export function useThreadHandoff(input: { + readonly threadRef: ScopedThreadRef | null; + readonly onError: (message: string) => void; +}) { + const pendingRef = useRef(false); + const navigate = useNavigate(); + const forkForHandoff = useAtomCommand(threadEnvironment.forkForHandoff, { + reportFailure: false, + }); + const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { + reportFailure: false, + }); + const reportFailure = ( + failure: Extract, { readonly _tag: "Failure" }>, + fallback: string, + ) => { + if (isAtomCommandInterrupted(failure)) return; + const error = squashAtomCommandFailure(failure); + input.onError(error instanceof Error ? error.message : fallback); + }; + const start = async (threadRef: ScopedThreadRef, focus: string | undefined) => { + const targetThreadId = newThreadId(); + const targetThreadRef = scopeThreadRef(threadRef.environmentId, targetThreadId); + const fork = await forkForHandoff({ + environmentId: threadRef.environmentId, + input: { + sourceThreadId: threadRef.threadId, + targetThreadId, + title: handoffThreadTitle(readThreadShell(threadRef)?.title ?? ""), + }, + }); + if (fork._tag === "Failure") { + reportFailure(fork, "Could not hand off this thread."); + return false; + } + if (!(await waitForThreadShell(targetThreadRef))) { + input.onError( + "The handoff thread was created, but it did not reach this client. Reconnect and open it from the sidebar.", + ); + return false; + } + const target = readThreadShell(targetThreadRef); + const text = focus?.trim(); + if (text && target) { + const turn = await startThreadTurn({ + environmentId: threadRef.environmentId, + input: { + threadId: targetThreadId, + message: { messageId: newMessageId(), role: "user", text, attachments: [] }, + runtimeMode: target.runtimeMode, + interactionMode: target.interactionMode, + createdAt: new Date().toISOString(), + }, + }); + // The new thread still opens; the focus can be sent there by hand. + if (turn._tag === "Failure") reportFailure(turn, "Could not send the handoff message."); + } + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), + }); + return true; + }; + const open = async (focus?: string) => { + const { threadRef } = input; + if (!threadRef || pendingRef.current) return false; + pendingRef.current = true; + return start(threadRef, focus).finally(() => { + pendingRef.current = false; + }); + }; + return { available: input.threadRef !== null, open: useStableCallback(open) }; +} diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 9b7223797190..821a05f09486 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -18,7 +18,7 @@ import { import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; -export type ComposerSlashCommand = "model" | "plan" | "default" | "side" | "btw"; +export type ComposerSlashCommand = "model" | "plan" | "default" | "side" | "btw" | "handoff"; export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; export interface ComposerTrigger { @@ -326,7 +326,7 @@ export function parseSideChatCommand(text: string): { prompt: string } | null { export function parseStandaloneComposerSlashCommand( text: string, -): Exclude | null { +): Exclude | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; From f7ea30a09a8ee6e4f5dfa850e209602150e22d38 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:07:49 -0600 Subject: [PATCH 5/5] docs(user): mobile guest links and /handoff --- docs/user/composer.md | 17 +++++++++++++++++ docs/user/guest-links.md | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index a3e06c9aed6f..8eaf2c5f3722 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -257,6 +257,23 @@ and appears once the provider reports usage. Open it for usage details and Claude instances can also compact idle threads for you. See [compact long conversations](./providers-claude.md#compact-long-conversations). +## Hand off to a new thread + +Send `/handoff` to continue in a new thread that keeps this conversation. T3 Code forks the +thread at its latest finished reply, names the new thread after it with "(handoff)", and opens +it. Text after the command, as in `/handoff now write the migration`, becomes the new thread's +first message. The original thread stays as it was. + +`/handoff` must be the whole message, and it waits until the first reply has finished and no +turn is running; the slash menu says why when it cannot run yet. On mobile it is also +**Hand off to new thread** in the thread's **More actions** menu (⋮) on Android, its **Share** +menu on iPhone and iPad, and the command palette. + +If the provider has its own `/handoff`, the slash menu lists T3 Code's first and the provider's +after it, with the provider's description. Pick the provider's from the menu to send it to the +agent instead. The new thread carries the conversation like any fork, also to another provider; +see [portable handoffs](./portable-handoffs.md). + ## Goals With Codex and Claude, send `/goal` followed by what "done" means, for example diff --git a/docs/user/guest-links.md b/docs/user/guest-links.md index a8631a6c93b2..add0212971fa 100644 --- a/docs/user/guest-links.md +++ b/docs/user/guest-links.md @@ -13,7 +13,9 @@ While the setting is off, every guest link on that machine stops working; links expired work again when you turn it back on. 1. Right-click the thread in the sidebar and choose **Share with guest**. It is also in the thread - menu and the command palette. + menu and the command palette. In the mobile app, open the thread's **More actions** menu (⋮) on + Android or its **Share** menu on iPhone and iPad, or search the command palette, and choose + **Share with guest**. The app copies the link and opens the share sheet. 2. Copy the link and send it to one person. It works in one browser and expires after 24 hours. Opening it again in that browser takes them back to the thread. 3. When they send a message, it appears at the end of the thread and the sidebar shows **Request**. @@ -22,6 +24,11 @@ expired work again when you turn it back on. Choose **Stop sharing** to end the link at once. Archiving the thread, creating a new link, or unlinking the machine from T3 Connect also ends it. You can send or dismiss guest messages from the mobile app. +While a link is active, the mobile menu offers **New guest link** and **Stop sharing**. A link is +shown only when it is created, so to send it again, make a new one. + +If the app says guest links are off, turn on **Settings → Labs → Guest links** in T3 Code on the +machine that runs the thread; the mobile app has no switch for it. ## What the guest sees