diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 00731ce5ae..489d79ba8b 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { withoutPairExecutors } from "@t3tools/client-runtime/state/pair"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -29,7 +30,8 @@ export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); - const threads = useThreadShells(); + const threadShells = useThreadShells(); + const threads = useMemo(() => withoutPairExecutors(threadShells), [threadShells]); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); diff --git a/apps/mobile/src/features/threads/PairStatusNotice.test.tsx b/apps/mobile/src/features/threads/PairStatusNotice.test.tsx new file mode 100644 index 0000000000..510646df37 --- /dev/null +++ b/apps/mobile/src/features/threads/PairStatusNotice.test.tsx @@ -0,0 +1,54 @@ +import type { PairState } from "@t3tools/client-runtime/state/pair"; +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { pairExecutorThreadId } from "@t3tools/shared/delegatedThreads"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("react-native", () => ({ + View: "div", + Pressable: ({ + children, + accessibilityLabel, + }: { + children: ReactNode; + accessibilityLabel?: string; + }) => , +})); +vi.mock("../../components/AppText", () => ({ AppText: "span" })); + +import { PairStatusNotice } from "./PairStatusNotice"; + +const executorId = pairExecutorThreadId(ThreadId.make("lead-1")); +type PairOn = Extract; +const on = (phase: PairOn["phase"]): PairOn => ({ + kind: "on", + executorId, + phase, + modelSelection: { instanceId: ProviderInstanceId.make("antigravity"), model: "gemini-3-flash" }, + activity: null, +}); +const render = (state: PairState) => + renderToStaticMarkup( + {}} />, + ); + +describe("PairStatusNotice", () => { + it("says who the thread is paired with and what the executor is doing", () => { + const markup = render(on("running")); + expect(markup).toContain("Paired with Gemini 3 Flash · Working"); + expect(markup).toContain("Open executor"); + expect(markup).toContain('aria-label="Paired with Gemini 3 Flash · Working. Open executor"'); + }); + + it("shows the executor's own line of activity when there is one", () => { + expect(render({ ...on("error"), activity: "Provider rejected the request." })).toContain( + "Provider rejected the request.", + ); + }); + + it("renders nothing while the pair is off or cannot exist", () => { + expect(render({ kind: "off", executorId })).toBe(""); + expect(render({ kind: "unsupported-lead", reason: "no" })).toBe(""); + }); +}); diff --git a/apps/mobile/src/features/threads/PairStatusNotice.tsx b/apps/mobile/src/features/threads/PairStatusNotice.tsx new file mode 100644 index 0000000000..99e538e174 --- /dev/null +++ b/apps/mobile/src/features/threads/PairStatusNotice.tsx @@ -0,0 +1,42 @@ +import { pairStatusLine, type PairState } from "@t3tools/client-runtime/state/pair"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; + +/** + * The lead's view of its pair on a phone: who it is paired with, what that + * executor is doing, and one tap to open it. Renders nothing while the pair is + * off, so the thread screen looks exactly as it did. + */ +export function PairStatusNotice(props: { + readonly state: PairState; + /** The executor model's display name. */ + readonly executorLabel: string; + readonly onOpenExecutor: () => void; +}) { + if (props.state.kind !== "on") { + return null; + } + + const line = pairStatusLine(props.state, props.executorLabel); + if (line === null) { + return null; + } + + return ( + + + {line} + {props.state.activity !== null ? ( + {props.state.activity} + ) : null} + Open executor + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad5c6c4a52..6b76fa7aa4 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -127,6 +127,8 @@ import { COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; +import type { PairState } from "@t3tools/client-runtime/state/pair"; +import { PairStatusNotice } from "./PairStatusNotice"; import { ThreadFeed } from "./ThreadFeed"; import type { SessionCompactionMenuAction } from "./sessionCompactionMenu"; import type { ThreadContentPresentation } from "./threadContentPresentation"; @@ -206,6 +208,9 @@ export interface ThreadDetailScreenProps { readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; + readonly pairState?: PairState; + readonly pairExecutorLabel?: string; + readonly onOpenPairExecutor?: () => void; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; @@ -1188,6 +1193,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > + {props.pairState !== undefined && + props.pairExecutorLabel !== undefined && + props.onOpenPairExecutor !== undefined ? ( + + ) : null} withoutPairExecutors(threadShells), [threadShells]); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const searchInputRef = useRef(null); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 32c693dce5..ea1de1a16d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -46,7 +46,7 @@ import { } from "@t3tools/shared/projectScripts"; import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { pairRewindBlockedReason } from "@t3tools/client-runtime/state/pair"; +import { pairRewindBlockedReason, resolvePairState } from "@t3tools/client-runtime/state/pair"; import { useThreadShells } from "../../state/entities"; import { useWorkspaceState } from "../../state/workspace"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; @@ -298,6 +298,31 @@ function ThreadRouteContent( "session agent depth update", ); const navigation = useNavigation(); + const pairState = useMemo(() => { + if (!selectedThread) { + return undefined; + } + return resolvePairState({ + threads: threadShells, + lead: { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + driverKind: null, + }, + }); + }, [threadShells, selectedThread]); + const pairExecutorLabel = pairState?.kind === "on" ? pairState.modelSelection.model : ""; + const onOpenPairExecutor = useCallback(() => { + if (!selectedThread || pairState?.kind !== "on") { + return; + } + navigation.dispatch( + StackActions.push("Thread", { + environmentId: selectedThread.environmentId, + threadId: pairState.executorId, + }), + ); + }, [navigation, pairState, selectedThread]); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; @@ -1215,6 +1240,9 @@ function ThreadRouteContent( } > ({ kind: "on", executorId: EXECUTOR, phase, modelSelection: SELECTION, activity }); -describe("pairPhaseLabel", () => { - it("describes the executor in plain words", () => { - expect(pairPhaseLabel("idle")).toBe("Waiting for a brief"); - expect(pairPhaseLabel("running")).toBe("Working"); - expect(pairPhaseLabel("needs-approval")).toBe("Needs your approval"); - expect(pairPhaseLabel("needs-input")).toBe("Has a question"); - expect(pairPhaseLabel("completed")).toBe("Finished"); - expect(pairPhaseLabel("interrupted")).toBe("Stopped"); - expect(pairPhaseLabel("error")).toBe("Failed"); - }); -}); - describe("PairControl trigger", () => { it("offers pairing in one word when the pair is off", () => { const html = renderToStaticMarkup(); diff --git a/apps/web/src/components/chat/PairControl.tsx b/apps/web/src/components/chat/PairControl.tsx index 108db6dade..fd9a892c94 100644 --- a/apps/web/src/components/chat/PairControl.tsx +++ b/apps/web/src/components/chat/PairControl.tsx @@ -1,4 +1,4 @@ -import type { PairExecutorPhase, PairState } from "@t3tools/client-runtime/state/pair"; +import { pairPhaseLabel, type PairState } from "@t3tools/client-runtime/state/pair"; import type { EnvironmentId, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { Link } from "@tanstack/react-router"; import { UsersIcon } from "lucide-react"; @@ -46,26 +46,6 @@ export function initialExecutorInstanceId( return (withModels ?? entries[0])?.instanceId; } -/** What the executor is doing, in the words the panel and the trigger's label use. */ -export function pairPhaseLabel(phase: PairExecutorPhase): string { - switch (phase) { - case "idle": - return "Waiting for a brief"; - case "running": - return "Working"; - case "needs-approval": - return "Needs your approval"; - case "needs-input": - return "Has a question"; - case "completed": - return "Finished"; - case "interrupted": - return "Stopped"; - case "error": - return "Failed"; - } -} - function executorLabel( props: Pick, ): string | null { diff --git a/docs/user/agent-delegation.md b/docs/user/agent-delegation.md index fa0a3ea127..948cbec037 100644 --- a/docs/user/agent-delegation.md +++ b/docs/user/agent-delegation.md @@ -162,8 +162,11 @@ stay as they are. You can ask for it: “reset your executor.” The executor appears under its parent in the sidebar like any delegated thread. Archive it to turn the pair off for that thread. Archiving, settling, or deleting the lead does the same to its executor. A lead cannot be rewound while its executor is working, because both use the same files: -stop the executor first, then rewind. The Pair -control is on the web and desktop apps; the mobile app does not have it yet. +stop the executor first, then rewind. + +You turn a pair on and off from the web and desktop apps. On a phone, a paired thread shows who it +is paired with and what the executor is doing above the composer, with one tap to open the executor; +executors stay out of the phone's thread lists, since you reach them from their lead. Pairing does not need **Pylon delegation** turned on. That setting decides whether agents may start other threads on their own; a pair is something you switch on yourself, for one thread. With the diff --git a/packages/client-runtime/src/state/pair.test.ts b/packages/client-runtime/src/state/pair.test.ts index 41305441a8..b036a799b4 100644 --- a/packages/client-runtime/src/state/pair.test.ts +++ b/packages/client-runtime/src/state/pair.test.ts @@ -14,6 +14,8 @@ import { PAIR_UNSUPPORTED_LEAD_REASON, isPairLeadSupported, pairRewindBlockedReason, + pairPhaseLabel, + pairStatusLine, pairExecutorCreateInput, resolvePairState, withoutPairExecutors, @@ -272,3 +274,29 @@ describe("pairRewindBlockedReason", () => { expect(ask([executor({ latestTurn: runningTurn, environmentId: OTHER_ENV })])).toBeNull(); }); }); + +describe("pair status wording", () => { + it("names every phase the same way on every client", () => { + expect(pairPhaseLabel("idle")).toBe("Waiting for a brief"); + expect(pairPhaseLabel("running")).toBe("Working"); + expect(pairPhaseLabel("needs-approval")).toBe("Needs your approval"); + expect(pairPhaseLabel("needs-input")).toBe("Has a question"); + expect(pairPhaseLabel("completed")).toBe("Finished"); + expect(pairPhaseLabel("interrupted")).toBe("Stopped"); + expect(pairPhaseLabel("error")).toBe("Failed"); + }); + + it("summarizes a pair in one line, and says nothing when there is none", () => { + const on = resolvePairState({ + threads: [executor({ latestTurn: runningTurn, hasPendingApprovals: true })], + lead: lead(), + }); + expect(pairStatusLine(on, "Gemini 3 Flash")).toBe( + "Paired with Gemini 3 Flash · Needs your approval", + ); + expect(pairStatusLine(resolvePairState({ threads: [], lead: lead() }), "x")).toBeNull(); + expect( + pairStatusLine(resolvePairState({ threads: [], lead: lead("antigravity") }), "x"), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/pair.ts b/packages/client-runtime/src/state/pair.ts index 4d537bb5e1..af8d1df5cf 100644 --- a/packages/client-runtime/src/state/pair.ts +++ b/packages/client-runtime/src/state/pair.ts @@ -165,3 +165,34 @@ export function pairRewindBlockedReason(input: { ? "Stop the executor before rewinding. It works in this thread's worktree and would write over the restored files." : null; } + +/** What the executor is doing, in the words every client uses. */ +export function pairPhaseLabel(phase: PairExecutorPhase): string { + switch (phase) { + case "idle": + return "Waiting for a brief"; + case "running": + return "Working"; + case "needs-approval": + return "Needs your approval"; + case "needs-input": + return "Has a question"; + case "completed": + return "Finished"; + case "interrupted": + return "Stopped"; + case "error": + return "Failed"; + } +} + +/** + * One line for a lead's thread screen: who it is paired with and what that + * executor is doing. Null while the pair is off or cannot exist. + */ +export function pairStatusLine(state: PairState, executorLabel: string): string | null { + if (state.kind !== "on") { + return null; + } + return `Paired with ${executorLabel} · ${pairPhaseLabel(state.phase)}`; +}