Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/features/threads/PairStatusNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => <button aria-label={accessibilityLabel}>{children}</button>,
}));
vi.mock("../../components/AppText", () => ({ AppText: "span" }));

import { PairStatusNotice } from "./PairStatusNotice";

const executorId = pairExecutorThreadId(ThreadId.make("lead-1"));
type PairOn = Extract<PairState, { kind: "on" }>;
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(
<PairStatusNotice state={state} executorLabel="Gemini 3 Flash" onOpenExecutor={() => {}} />,
);

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("");
});
});
42 changes: 42 additions & 0 deletions apps/mobile/src/features/threads/PairStatusNotice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${line}. Open executor`}
onPress={props.onOpenExecutor}
className="mb-2 gap-1 rounded-2xl border border-border bg-card px-3.5 py-3"
>
<View className="gap-1">
<Text className="text-sm font-t3-bold text-foreground">{line}</Text>
{props.state.activity !== null ? (
<Text className="text-sm leading-snug text-foreground-muted">{props.state.activity}</Text>
) : null}
<Text className="text-sm font-t3-bold text-primary">Open executor</Text>
</View>
</Pressable>
);
}
14 changes: 14 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1188,6 +1193,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
: undefined
}
>
{props.pairState !== undefined &&
props.pairExecutorLabel !== undefined &&
props.onOpenPairExecutor !== undefined ? (
<PairStatusNotice
state={props.pairState}
executorLabel={props.pairExecutorLabel}
onOpenExecutor={props.onOpenPairExecutor}
/>
) : null}
<GlassBlurTargetContext value={feedBlurTarget}>
<ThreadComposer
editorRef={composerEditorRef}
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { withoutPairExecutors } from "@t3tools/client-runtime/state/pair";
import { useProjects, useThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
Expand Down Expand Up @@ -154,7 +155,8 @@ function ThreadNavigationSidebarPane(

const insets = useSafeAreaInsets();
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 searchInputRef = useRef<TextInput>(null);
Expand Down
30 changes: 29 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1215,6 +1240,9 @@ function ThreadRouteContent(
}
>
<ThreadDetailScreen
pairState={pairState}
pairExecutorLabel={pairExecutorLabel}
onOpenPairExecutor={onOpenPairExecutor}
selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
contentPresentation={contentPresentation}
screenTone={connectionTone(routeConnectionState)}
Expand Down
13 changes: 0 additions & 13 deletions apps/web/src/components/chat/PairControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { deriveProviderInstanceEntries } from "../../providerInstances";
import {
PairControl,
PairControlPanel,
pairPhaseLabel,
type PairControlProps,
initialExecutorInstanceId,
} from "./PairControl";
Expand Down Expand Up @@ -65,18 +64,6 @@ const on = (
activity: string | null = null,
): PairState => ({ 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(<PairControl {...props()} />);
Expand Down
22 changes: 1 addition & 21 deletions apps/web/src/components/chat/PairControl.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<PairControlProps, "executorSelection" | "modelOptionsByInstance">,
): string | null {
Expand Down
7 changes: 5 additions & 2 deletions docs/user/agent-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/client-runtime/src/state/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
PAIR_UNSUPPORTED_LEAD_REASON,
isPairLeadSupported,
pairRewindBlockedReason,
pairPhaseLabel,
pairStatusLine,
pairExecutorCreateInput,
resolvePairState,
withoutPairExecutors,
Expand Down Expand Up @@ -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();
});
});
31 changes: 31 additions & 0 deletions packages/client-runtime/src/state/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
}
Loading