diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index e3db494219d71..9ffb3bed0b083 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 e9e9e50d4527f..bb07c024bb2cb 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/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index ea2928f531374..3f2b8ea4ed0f3 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/ThreadGuestShareBar.tsx b/apps/mobile/src/features/threads/ThreadGuestShareBar.tsx index 23d8711882afd..eea3d3c866502 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 a07aedfabf7ed..d5f908ff582d2 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 0000000000000..d5d74669fbf9c --- /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 0000000000000..2fcb02525eebb --- /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/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index c0044a74f7944..b897d92e15aee 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 72987599f48f6..6c16514886060 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, + }), }; } diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx index 8363af1a3e191..029cf43ff0cf3 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 0000000000000..ba98c44987261 --- /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.", + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a1c1c7bc91a5b..1ecdfc4c4a0df 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 25b7f5ce6f87c..ea9d762ee18d8 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 0000000000000..b55aaba31272b --- /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 0c5c04e28caef..ab0e7779f8809 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 5f778ac6cac1e..eeb4d1e0da954 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 98084e9795443..6e8d6d7f57e5f 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 0000000000000..b26328ffa1b76 --- /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 9b7223797190e..821a05f094866 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; diff --git a/docs/user/composer.md b/docs/user/composer.md index a3e06c9aed6f7..8eaf2c5f37221 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 a8631a6c93b28..add0212971fad 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 diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 777f7e2adb4f5..82522bf37992a 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 88a5759c5ed50..23d1a0a6e7d37 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 19542c4459091..634d9b1ed3a20 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 0000000000000..6374a56ab9189 --- /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 0000000000000..3c6e1f8112dfd --- /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; +}