diff --git a/apps/mobile/src/features/threads/PairStatusNotice.tsx b/apps/mobile/src/features/threads/PairStatusNotice.tsx index 99e538e174..ecbc8b2d62 100644 --- a/apps/mobile/src/features/threads/PairStatusNotice.tsx +++ b/apps/mobile/src/features/threads/PairStatusNotice.tsx @@ -25,10 +25,12 @@ export function PairStatusNotice(props: { return ( {line} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 5e7336d834..204c737cc4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -168,10 +168,13 @@ import { } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; +import { pairSettingsRow, type PairLead } from "@t3tools/client-runtime/state/pair-control"; +import { delegatedParentThreadId } from "@t3tools/shared/delegatedThreads"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, } from "./ThreadSettingsSheet"; +import { usePairToggle } from "./usePairToggle"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -1562,6 +1565,53 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); + const isDelegatedThread = delegatedParentThreadId(props.selectedThread.id) !== null; + const lead = useMemo( + () => ({ + id: props.selectedThread.id, + projectId: props.selectedThread.projectId, + title: props.selectedThread.title, + runtimeMode: props.selectedThread.runtimeMode, + branch: props.selectedThread.branch, + worktreePath: props.selectedThread.worktreePath, + session: props.selectedThread.session, + }), + [props.selectedThread], + ); + const leadDriverKind = composerAuthority.provider?.driver ?? null; + const { + state: pairState, + executorSelection, + lockedReason: pairLockedReasonValue, + onToggle: onTogglePair, + } = usePairToggle({ + environmentId: props.environmentId, + projectId: props.selectedThread.projectId, + lead, + leadDriverKind, + }); + const executorLabel = executorSelection?.model ?? ""; + const pairRow = useMemo( + () => + pairSettingsRow({ + state: pairState, + lockedReason: pairLockedReasonValue, + executorSelection, + executorLabel, + }), + [executorLabel, executorSelection, pairLockedReasonValue, pairState], + ); + const pairSession = useMemo( + () => + isDelegatedThread + ? undefined + : { + ...pairRow, + onToggle: onTogglePair, + }, + [isDelegatedThread, onTogglePair, pairRow], + ); + const settingsOwnerId = composerOwnerKey; const settingsRouteSession = useMemo( () => ({ @@ -1583,12 +1633,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (!props.sessionInputBlocked) props.onUpdateRuntimeMode(mode); }, getModelDisabledReason: getModelChangeDisabledReason, + ...(pairSession ? { pair: pairSession } : {}), }), [ confirmSessionHarnessRefinement, currentModelSelection, currentRuntimeMode, getModelChangeDisabledReason, + pairSession, props.onUpdateModelSelection, props.onUpdateRuntimeMode, props.sessionInputBlocked, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6b76fa7aa4..e5755c4c12 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1068,6 +1068,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {props.pairState !== undefined && + props.pairExecutorLabel !== undefined && + props.onOpenPairExecutor !== undefined ? ( + + ) : null} {props.feedbackSubmissions.map((submission) => ( - {props.pairState !== undefined && - props.pairExecutorLabel !== undefined && - props.onOpenPairExecutor !== undefined ? ( - - ) : null} void; readonly isLast?: boolean; + readonly disabled?: boolean; }) { return ( {props.label} @@ -327,6 +329,12 @@ type ThreadSettingsSessionProps = { readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; + readonly pair?: { + readonly value: boolean; + readonly disabled: boolean; + readonly detail: string | null; + readonly onToggle: (on: boolean) => void; + }; }; export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { @@ -393,6 +401,12 @@ type ThreadSettingsSessionValue = { readonly setSearchQuery: (query: string) => void; readonly setShowLegacy: (showLegacy: boolean) => void; readonly toggleProvider: (providerKey: string) => void; + readonly pair?: { + readonly value: boolean; + readonly disabled: boolean; + readonly detail: string | null; + readonly onToggle: (on: boolean) => void; + }; }; const ThreadSettingsSessionContext = createContext(null); @@ -526,6 +540,7 @@ function ThreadSettingsSessionProvider( setSearchQuery, setShowLegacy: setShowLegacyToggle, toggleProvider, + pair: props.pair, }), [ applyOptionChange, @@ -541,6 +556,7 @@ function ThreadSettingsSessionProvider( providerFilter, props.getModelDisabledReason, props.onUpdateRuntimeMode, + props.pair, props.providerGroups, displayedRuntimeMode, runtimeChoices, @@ -781,6 +797,26 @@ function ThreadSettingsOptionsItem(props: { + {session.pair !== undefined ? ( + <> + Pair + + + + {session.pair.detail !== null ? ( + + {session.pair.detail} + + ) : null} + + ) : null} + {Platform.OS !== "ios" && session.hasLegacyModels ? ( <> diff --git a/apps/mobile/src/features/threads/usePairToggle.ts b/apps/mobile/src/features/threads/usePairToggle.ts new file mode 100644 index 0000000000..bf6462faa3 --- /dev/null +++ b/apps/mobile/src/features/threads/usePairToggle.ts @@ -0,0 +1,202 @@ +import { + type PairLead, + pairLockedReason, + pairToggleStep, + resolveExecutorSelection, + shouldRestartLeadSession, +} from "@t3tools/client-runtime/state/pair-control"; +import { type PairState, resolvePairState } from "@t3tools/client-runtime/state/pair"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import { pairExecutorThreadId } from "@t3tools/shared/delegatedThreads"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { useCallback, useMemo } from "react"; +import { Alert } from "react-native"; + +import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export interface UsePairToggleInput { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId | null; + readonly lead: PairLead | null; + readonly leadDriverKind: string | null | undefined; +} + +export interface UsePairToggleResult { + readonly state: PairState; + readonly executorSelection: ModelSelection | null; + readonly lockedReason: string | null; + readonly onToggle: (on: boolean) => Promise; +} + +export function usePairToggle(input: UsePairToggleInput): UsePairToggleResult { + const threads = useThreadShells(); + const projects = useProjects(); + const selectedProject = useMemo( + () => + input.projectId === null + ? null + : (projects.find( + (project) => + project.environmentId === input.environmentId && project.id === input.projectId, + ) ?? null), + [input.environmentId, input.projectId, projects], + ); + const selectedEnvironmentServerConfig = useEnvironmentServerConfig(input.environmentId); + const projectSettings = useMemo( + () => + resolveProjectSettings( + selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + input.projectId, + selectedProject, + ).settings, + [input.projectId, selectedEnvironmentServerConfig?.settings, selectedProject], + ); + const defaultSelection = projectSettings.delegationDefaultModelSelection; + const childRuntimeMode = projectSettings.delegationChildRuntimeMode; + + const state = useMemo(() => { + if (input.lead === null) { + return { + kind: "off", + executorId: pairExecutorThreadId(ThreadId.make("no-thread")), + }; + } + return resolvePairState({ + threads, + lead: { + environmentId: input.environmentId, + threadId: input.lead.id, + driverKind: input.leadDriverKind, + }, + }); + }, [input.environmentId, input.lead, input.leadDriverKind, threads]); + + const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); + const unarchiveThread = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); + const archiveThread = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const stopSession = useAtomCommand(threadEnvironment.stopSession, { reportFailure: false }); + + const executorSelection = useMemo( + () => resolveExecutorSelection({ state, picked: null, defaultSelection }), + [defaultSelection, state], + ); + const lockedReason = useMemo(() => pairLockedReason(input.lead), [input.lead]); + + const onToggle = useCallback( + async (on: boolean) => { + const step = pairToggleStep({ + on, + state, + lead: input.lead, + executorSelection, + childRuntimeMode, + }); + if (step === null) { + return; + } + + if (step.kind === "create") { + const createResult = await createThread({ + environmentId: input.environmentId, + input: { + ...step.input, + createdAt: new Date().toISOString(), + }, + }); + if (createResult._tag === "Failure") { + const unarchiveResult = await unarchiveThread({ + environmentId: input.environmentId, + input: { + threadId: step.input.threadId, + }, + }); + if (unarchiveResult._tag === "Failure") { + if (!isAtomCommandInterrupted(unarchiveResult)) { + const error = squashAtomCommandFailure(unarchiveResult); + const message = + error instanceof Error + ? error.message + : "An error occurred while creating the new thread."; + Alert.alert("Could not change the pair", message); + } + return; + } + } + } else if (step.kind === "delete") { + const deleteResult = await deleteThread({ + environmentId: input.environmentId, + input: { + threadId: step.threadId, + }, + }); + if (deleteResult._tag === "Failure" && !isAtomCommandInterrupted(deleteResult)) { + const error = squashAtomCommandFailure(deleteResult); + const message = + error instanceof Error ? error.message : "An error occurred while deleting the thread."; + Alert.alert("Could not change the pair", message); + return; + } + } else if (step.kind === "archive") { + const archiveResult = await archiveThread({ + environmentId: input.environmentId, + input: { + threadId: step.threadId, + }, + }); + if (archiveResult._tag === "Failure" && !isAtomCommandInterrupted(archiveResult)) { + const error = squashAtomCommandFailure(archiveResult); + const message = + error instanceof Error + ? error.message + : "An error occurred while archiving the thread."; + Alert.alert("Could not change the pair", message); + return; + } + } + + if (input.lead !== null && shouldRestartLeadSession(input.lead)) { + void stopSession({ + environmentId: input.environmentId, + input: { + threadId: input.lead.id, + }, + }); + } + }, + [ + archiveThread, + childRuntimeMode, + createThread, + deleteThread, + executorSelection, + input.environmentId, + input.lead, + state, + stopSession, + unarchiveThread, + ], + ); + + return useMemo( + () => ({ + state, + executorSelection, + lockedReason, + onToggle, + }), + [executorSelection, lockedReason, onToggle, state], + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e52f269703..4e9508340d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1086,7 +1086,7 @@ import { ProviderBindingConflictNotice } from "./ProviderBindingConflictNotice"; import { QuickQuestionDialog } from "./QuickQuestionDialog"; import { SessionResourcesDialog } from "./SessionResourcesDialog"; import { PairControl } from "./PairControl"; -import type { PairLead } from "./pairControl.logic"; +import type { PairLead } from "@t3tools/client-runtime/state/pair-control"; import { usePairControl } from "./usePairControl"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; diff --git a/apps/web/src/components/chat/usePairControl.ts b/apps/web/src/components/chat/usePairControl.ts index 65e21b4b59..f3b3d2b84e 100644 --- a/apps/web/src/components/chat/usePairControl.ts +++ b/apps/web/src/components/chat/usePairControl.ts @@ -27,7 +27,7 @@ import { resolveExecutorSelection, shouldRestartLeadSession, type PairLead, -} from "./pairControl.logic"; +} from "@t3tools/client-runtime/state/pair-control"; export function usePairControl(input: { readonly environmentId: EnvironmentId; diff --git a/docs/user/agent-delegation.md b/docs/user/agent-delegation.md index 948cbec037..371cc8728f 100644 --- a/docs/user/agent-delegation.md +++ b/docs/user/agent-delegation.md @@ -164,9 +164,11 @@ the pair off for that thread. Archiving, settling, or deleting the lead does the executor. A lead cannot be rewound while its executor is working, because both use the same files: 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. +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. To turn a pair on or off there, open the thread's settings and use +**Pair with an executor**. The phone has no executor picker: it pairs with the project's **Default +delegation model**, and the switch says so when none is set. 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/package.json b/packages/client-runtime/package.json index 6487da748e..9a52c6872b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -255,6 +255,10 @@ "types": "./src/state/pair.ts", "default": "./src/state/pair.ts" }, + "./state/pair-control": { + "types": "./src/state/pairControl.ts", + "default": "./src/state/pairControl.ts" + }, "./state/delegation-notice": { "types": "./src/state/delegationNotice.ts", "default": "./src/state/delegationNotice.ts" diff --git a/apps/web/src/components/chat/pairControl.logic.test.ts b/packages/client-runtime/src/state/pairControl.test.ts similarity index 77% rename from apps/web/src/components/chat/pairControl.logic.test.ts rename to packages/client-runtime/src/state/pairControl.test.ts index 519c3a7a0c..3189440af9 100644 --- a/apps/web/src/components/chat/pairControl.logic.test.ts +++ b/packages/client-runtime/src/state/pairControl.test.ts @@ -1,15 +1,16 @@ -import { PAIR_UNSUPPORTED_LEAD_REASON, type PairState } from "@t3tools/client-runtime/state/pair"; +import { PAIR_UNSUPPORTED_LEAD_REASON, type PairState } from "./pair.ts"; import { ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; import { pairExecutorThreadId } from "@t3tools/shared/delegatedThreads"; import { describe, expect, it } from "vite-plus/test"; import { pairLockedReason, + pairSettingsRow, pairToggleStep, resolveExecutorSelection, shouldRestartLeadSession, type PairLead, -} from "./pairControl.logic"; +} from "./pairControl.ts"; const LEAD_ID = ThreadId.make("lead-1"); const EXECUTOR = pairExecutorThreadId(LEAD_ID); @@ -164,3 +165,51 @@ describe("resolveExecutorSelection", () => { ).toBeNull(); }); }); + +describe("pairSettingsRow", () => { + const row = (overrides: Partial[0]> = {}) => + pairSettingsRow({ + state: off, + lockedReason: null, + executorSelection: SELECTION, + executorLabel: "Gemini 3 Flash", + ...overrides, + }); + + it("offers the switch when a default executor model exists, naming it", () => { + expect(row()).toEqual({ + value: false, + disabled: false, + detail: "Pairs this thread with Gemini 3 Flash.", + }); + }); + + it("shows the pair and what the executor is doing while it is on", () => { + expect(row({ state: on("running") })).toEqual({ + value: true, + disabled: false, + detail: "Paired with Gemini 3 Flash ยท Working", + }); + }); + + it("explains itself whenever the switch cannot be used", () => { + expect(row({ executorSelection: null, executorLabel: "" })).toEqual({ + value: false, + disabled: true, + detail: "Set a default delegation model in the project's settings to pair from here.", + }); + expect(row({ lockedReason: "Changes apply between turns." })).toEqual({ + value: false, + disabled: true, + detail: "Changes apply between turns.", + }); + expect(row({ state: on("running"), lockedReason: "Changes apply between turns." })).toEqual({ + value: true, + disabled: true, + detail: "Changes apply between turns.", + }); + expect( + row({ state: { kind: "unsupported-lead", reason: PAIR_UNSUPPORTED_LEAD_REASON } }), + ).toEqual({ value: false, disabled: true, detail: PAIR_UNSUPPORTED_LEAD_REASON }); + }); +}); diff --git a/apps/web/src/components/chat/pairControl.logic.ts b/packages/client-runtime/src/state/pairControl.ts similarity index 71% rename from apps/web/src/components/chat/pairControl.logic.ts rename to packages/client-runtime/src/state/pairControl.ts index c8f55abfe8..2b7e59d6cb 100644 --- a/apps/web/src/components/chat/pairControl.logic.ts +++ b/packages/client-runtime/src/state/pairControl.ts @@ -1,8 +1,9 @@ import { pairExecutorCreateInput, + pairStatusLine, type PairExecutorCreateInput, type PairState, -} from "@t3tools/client-runtime/state/pair"; +} from "./pair.ts"; import type { ModelSelection, OrchestrationSession, @@ -121,3 +122,44 @@ export function resolveExecutorSelection(input: { } return input.picked ?? input.defaultSelection; } + +/** + * The phone's Pair switch. A phone has no executor picker: the executor is the + * project's default delegation model, so without one the switch explains where + * to set it. `detail` is the one line shown under the switch. + */ +export function pairSettingsRow(input: { + readonly state: PairState; + readonly lockedReason: string | null; + /** The executor's own model while the pair is on; the project's default while it is off. */ + readonly executorSelection: ModelSelection | null; + /** That model's display name, or "" when there is none. */ + readonly executorLabel: string; +}): { readonly value: boolean; readonly disabled: boolean; readonly detail: string | null } { + if (input.state.kind === "unsupported-lead") { + return { value: false, disabled: true, detail: input.state.reason }; + } + const value = input.state.kind === "on"; + if (input.lockedReason !== null && input.lockedReason.length > 0) { + return { value, disabled: true, detail: input.lockedReason }; + } + if (input.state.kind === "on") { + return { + value: true, + disabled: false, + detail: pairStatusLine(input.state, input.executorLabel), + }; + } + if (input.executorSelection === null) { + return { + value: false, + disabled: true, + detail: "Set a default delegation model in the project's settings to pair from here.", + }; + } + return { + value: false, + disabled: false, + detail: `Pairs this thread with ${input.executorLabel}.`, + }; +}