From 9d623002aa5de824e50b946892a13a2e46a89379 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:24:10 +0200 Subject: [PATCH 1/6] fix: make provider handoffs readable and available after import --- .../src/features/threads/thread-handoff-row.tsx | 14 +++++++++++--- .../threads/thread-provider-switching.test.ts | 14 ++++++++++++++ .../features/threads/thread-provider-switching.ts | 10 ++++------ apps/web/src/components/ChatView.tsx | 14 +++++++++----- apps/web/src/components/Sidebar.tsx | 8 +------- .../src/components/chat/MessagesTimeline.test.tsx | 2 ++ apps/web/src/components/chat/MessagesTimeline.tsx | 9 ++++----- .../src/components/chat/TimelineSystemDivider.tsx | 11 +++++++++-- apps/web/src/components/chat/V2LifecycleRow.tsx | 12 ++++++------ .../client-runtime/src/state/threadWorkflows.ts | 9 +++++++++ 10 files changed, 69 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-handoff-row.tsx b/apps/mobile/src/features/threads/thread-handoff-row.tsx index 4328809c9c27..4007fe183dba 100644 --- a/apps/mobile/src/features/threads/thread-handoff-row.tsx +++ b/apps/mobile/src/features/threads/thread-handoff-row.tsx @@ -91,16 +91,24 @@ function HandoffEndpoint(props: { Alert.alert(label)} + onPress={() => + Alert.alert( + label, + provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId, + ) + } > + + {label} + ); } diff --git a/apps/mobile/src/features/threads/thread-provider-switching.test.ts b/apps/mobile/src/features/threads/thread-provider-switching.test.ts index 1db00af52d82..63a14853b3eb 100644 --- a/apps/mobile/src/features/threads/thread-provider-switching.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-switching.test.ts @@ -55,6 +55,20 @@ describe("threadAllowsProviderSwitch", () => { expect(threadAllowsProviderSwitch({ thread: unstartedThread, projection: null })).toBe(true); }); + it("allows an imported thread to hand off before opening a provider session", () => { + expect( + threadAllowsProviderSwitch({ + thread: startedThread, + projection: { + thread: { id: "thread", activeProviderThreadId: null }, + runs: [], + providerThreads: [], + providerSessions: [], + } as unknown as OrchestrationV2ThreadProjection, + }), + ).toBe(true); + }); + it("keeps a started thread bound until its projection resolves a session", () => { expect(threadAllowsProviderSwitch({ thread: startedThread, projection: null })).toBe(false); }); diff --git a/apps/mobile/src/features/threads/thread-provider-switching.ts b/apps/mobile/src/features/threads/thread-provider-switching.ts index 791350fd6c2e..8daa3636b3ea 100644 --- a/apps/mobile/src/features/threads/thread-provider-switching.ts +++ b/apps/mobile/src/features/threads/thread-provider-switching.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows"; +import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; type ThreadStartMarkers = Pick< @@ -24,9 +24,7 @@ export function threadAllowsProviderSwitch(input: { readonly thread: ThreadStartMarkers | null | undefined; readonly projection: OrchestrationV2ThreadProjection | null | undefined; }): boolean { - const session = input.projection ? resolveThreadProviderSession(input.projection) : null; - if (session !== null) { - return session.capabilities.sessions.supportsProviderSwitchingViaHandoff; - } - return !threadShellHasStarted(input.thread); + return input.projection != null + ? threadSupportsProviderHandoff(input.projection) + : !threadShellHasStarted(input.thread); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17c712ed3d04..c10edc5a6822 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -77,7 +77,7 @@ import { deriveLatestThreadRun, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; -import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows"; +import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -1997,12 +1997,10 @@ export default function ChatView(props: ChatViewProps) { () => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)), [serverProjection], ); - const activeProviderSession = useMemo( - () => (serverProjection === null ? null : resolveThreadProviderSession(serverProjection)), + const supportsProviderSwitchingViaHandoff = useMemo( + () => threadSupportsProviderHandoff(serverProjection), [serverProjection], ); - const supportsProviderSwitchingViaHandoff = - activeProviderSession?.capabilities.sessions.supportsProviderSwitchingViaHandoff === true; const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null); const activeActivityRun = isServerThread ? serverActivityRun : (activeThread?.latestRun ?? null); const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null); @@ -10247,6 +10245,12 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} listRef={legendListRef} timelineEntries={displayedTimeline.entries} + providerStatuses={ + environmentById.get( + displayedThreadRef?.environmentId ?? activeThread.environmentId, + )?.serverConfig?.providers ?? EMPTY_PROVIDERS + } + runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])} latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun} runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId} turnDiffSummaries={ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 50146e17001d..6eeea5055847 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -322,12 +322,6 @@ function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } -// Trailing provider glyphs for a row. A thread that has been handed off -// between providers draws its earlier owners behind the current one, so the -// list shows where the thread has been without widening the row. Rendered -// back to front so DOM order matches visual layering. No separator ring: row -// surfaces vary (active, selected, draft, hover), so earlier glyphs are -// shrunk and dimmed instead, which reads as depth on any background. function SidebarProviderStack(props: { thread: SidebarThreadSummary; providerEntryByInstanceId: ReadonlyMap; @@ -357,7 +351,7 @@ function SidebarProviderStack(props: { return {current}; } return ( - + {stack.slice(0, -1).map((instanceId) => { const entry = props.providerEntryByInstanceId.get(instanceId); if (entry === undefined) return null; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index d4f9c6e7f358..b22dcaad14e8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -229,6 +229,8 @@ function buildProps() { listRef: createRef(), latestRun: null, turnDiffSummaries: [], + providerStatuses: [], + runs: [], routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, onOpenThread: () => {}, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b084b6d17c3e..add5e83dbcd5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -357,7 +357,6 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const satisfies MaintainScrollAtEndOptions; -const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray = []; const EMPTY_TIMELINE_RUNS: ReadonlyArray = []; // Streamed text lands a paragraph at a time. A smooth scroll to the end // turns each landing into a short glide instead of a jump. Thread switches @@ -429,8 +428,8 @@ interface MessagesTimelineProps { timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray>; - providerStatuses?: ReadonlyArray; - runs?: ReadonlyArray; + providerStatuses: ReadonlyArray; + runs: ReadonlyArray; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; @@ -500,8 +499,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, - providerStatuses = EMPTY_TIMELINE_PROVIDERS, - runs: runsProp = EMPTY_TIMELINE_RUNS, + providerStatuses, + runs: runsProp, anchorMessageId, onAnchorReady, onAnchorSizeChanged, diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx index d5a5823c0b91..b35c03e08617 100644 --- a/apps/web/src/components/chat/TimelineSystemDivider.tsx +++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx @@ -19,7 +19,12 @@ export function TimelineSystemDivider(props: { {Icon ? : null} {props.label} {props.detail ? ( - + {props.showDetailSeparator === false ? null : "·\u00a0"} {props.detail} @@ -52,7 +57,9 @@ export function TimelineSystemDivider(props: { {props.actionLabel} ) : ( - {content} + + {content} + )}