From 194d18d16e6c53b68feb9791e6bacaca2727d7db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:37:28 +1100 Subject: [PATCH 1/4] fix(client): show reconnecting instead of stale working status --- .../features/threads/thread-list-v2-items.tsx | 18 ++++ .../src/features/threads/threadListV2.test.ts | 97 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 12 ++- apps/mobile/src/state/threads.ts | 2 + .../src/state/use-composer-drafts.test.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 88 +++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 35 ++++++- .../components/sidebar/SidebarThreadRow.tsx | 85 +++++++++------- apps/web/src/composerDraftStore.test.ts | 1 + apps/web/src/state/threads.ts | 2 + packages/client-runtime/src/state/models.ts | 30 ++++++ .../client-runtime/src/state/presentation.ts | 5 + .../src/state/threadShell.test.ts | 61 +++++++++++- .../client-runtime/src/state/threadShell.ts | 27 ++++-- 14 files changed, 418 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 39c5ac82362a4..b757adf88efc2 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -87,6 +87,24 @@ interface StatusLabel { readonly iconTintClassName: string; } const STATUS_LABEL_BY_STATUS: Partial> = { + connecting: { + label: "Connecting", + icon: "wifi.slash", + className: "text-foreground-muted", + iconTintClassName: "accent-foreground-muted", + }, + reconnecting: { + label: "Reconnecting", + icon: "wifi.slash", + className: "text-foreground-muted", + iconTintClassName: "accent-foreground-muted", + }, + offline: { + label: "Offline", + icon: "wifi.slash", + className: "text-foreground-muted", + iconTintClassName: "accent-foreground-muted", + }, approval: { label: "Approval", icon: "exclamationmark.shield", diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index ff438f006f1cb..e28d04dbcafc7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -100,6 +100,89 @@ describe("resolveThreadListV2SnoozePresets", () => { }); describe("resolveThreadListV2Status", () => { + it.each(["preparing", "queued", "starting", "running", "waiting"] as const)( + "qualifies a cached %s runtime while reconnecting", + (status) => { + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + status, + activeRunId: RunId.make("run-disconnected"), + latestRunId: RunId.make("run-disconnected"), + }), + ); + expect(resolveThreadListV2Status({ ...thread, connectionPhase: "reconnecting" })).toBe( + "reconnecting", + ); + }, + ); + + it.each(["failed", "completed", "cancelled", "interrupted"] as const)( + "preserves a cached %s outcome while disconnected", + (status) => { + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + status, + latestRunId: RunId.make("run-ended"), + latestRunCompletedAt: DateTime.makeUnsafe(NOW), + lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), + }), + ); + const disconnected = { ...thread, connectionPhase: "reconnecting" as const }; + expect(resolveThreadListV2Status(disconnected)).toBe( + status === "failed" ? "failed" : "ready", + ); + expect(threadHasUnseenCompletion(disconnected)).toBe(threadHasUnseenCompletion(thread)); + }, + ); + + it("does not claim live work while the environment is reconnecting", () => { + const thread = { + ...presentThreadShell( + environmentId, + makeRawThreadShell({ + status: "running", + activeRunId: RunId.make("run-disconnected"), + latestRunId: RunId.make("run-disconnected"), + }), + ), + connectionPhase: "reconnecting" as const, + }; + expect(resolveThreadListV2Status(thread)).toBe("reconnecting"); + }); + + it.each([ + ["connecting", "connecting"], + ["reconnecting", "reconnecting"], + ["offline", "offline"], + ["error", "offline"], + ["unsupported", "offline"], + ["available", "offline"], + ["connected", "working"], + [undefined, "working"], + ] as const)( + "presents cached running work with connection phase %s", + (connectionPhase, expected) => { + const thread = makeThread({ + id: ThreadId.make("disconnected"), + title: "Disconnected", + runtime: { + status: "running", + activeRunId: RunId.make("run-disconnected"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, + }, + connectionPhase, + }); + expect(resolveThreadListV2Status(thread)).toBe(expected); + expect(resolveThreadListV2Status({ ...thread, hasPendingApprovals: true })).toBe("approval"); + expect(resolveThreadListV2Status({ ...thread, hasPendingUserInput: true })).toBe("input"); + }, + ); + it("presents decoded thread waits as Waiting while preserving requests and older servers", () => { const raw = makeRawThreadShell({ status: "running", @@ -1625,6 +1708,20 @@ describe("Working section beta", () => { expect(off.workingCount).toBe(0); }); + it("preserves pins and Working membership across connection loss and recovery", () => { + const before = build({ workingShelfExpanded: true }); + for (const connectionPhase of ["reconnecting", "offline", "connected"] as const) { + const layout = build({ + workingShelfExpanded: true, + threads: threads.map((thread) => ({ ...thread, connectionPhase })), + }); + expect(ids(layout)).toEqual(ids(before)); + expect(layout.workingCount).toBe(before.workingCount); + expect(layout.items[0]?.thread.id).toBe("pinned-working"); + expect(layout.items[0]?.thread.runtime?.status).toBe("running"); + } + }); + it("keeps an inactive row in Working during the shared grace and returns it on expiry", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(NOW)); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ad02dd1192d63..88a9598e8271f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -10,7 +10,10 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; +import { + resolveThreadConnectionStatus, + resolveThreadProviderStack, +} from "@t3tools/client-runtime/state/models"; import { threadSearchMatchKey, threadSearchSubject, @@ -62,6 +65,9 @@ export function resolveThreadListV2ProviderDrivers( } export type ThreadListV2Status = + | "connecting" + | "reconnecting" + | "offline" | "approval" | "input" | "request" @@ -207,6 +213,8 @@ export function resolveThreadListV2Status( | "pendingGuestMessageCount" | "runtime" | "blockedOnThreadWait" + | "connectionPhase" + | "pendingBackgroundTasks" >, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { @@ -219,6 +227,8 @@ export function resolveThreadListV2Status( if ((thread.pendingGuestMessageCount ?? 0) > 0) { return "request"; } + const connectionStatus = resolveThreadConnectionStatus(thread); + if (connectionStatus !== null) return connectionStatus; if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 4cb0f92b4ee83..75b6974a5b89e 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -22,6 +22,7 @@ import { selectThreadCreationDetail, } from "./pending-thread-creation"; import { environmentShell } from "./shell"; +import { environmentPresentations } from "./presentation"; import { threadOutboxManager } from "./thread-outbox"; export const threadFamilies = createThreadFamilyAtoms( @@ -42,6 +43,7 @@ export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( ); export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, + connectionPhaseAtom: environmentPresentations.connectionPhaseAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a10431d802b3d..2ed5ecd9e0a38 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -312,6 +312,7 @@ function modelReconciliationHarness( }); const shells = createEnvironmentThreadShellAtoms({ catalogValueAtom, + connectionPhaseAtom: () => Atom.make("connected" as const), snapshotAtom: () => snapshotAtom, }); const selectedShell = shells.threadShellAtom({ environmentId, threadId: selectedThreadId }); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..3ce6618f72c6c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -442,6 +442,94 @@ describe("resolveThreadStatusPill", () => { }, }; + it("does not claim live work while the environment is reconnecting", () => { + const thread = { ...baseThread, connectionPhase: "reconnecting" as const }; + expect(resolveSidebarThreadStatus(thread)).toBe("reconnecting"); + expect(resolveThreadStatusPill({ thread })).toMatchObject({ + label: "Reconnecting", + pulse: false, + }); + }); + + it.each([ + ["connecting", "connecting", "Connecting"], + ["reconnecting", "reconnecting", "Reconnecting"], + ["offline", "offline", "Offline"], + ["error", "offline", "Offline"], + ["unsupported", "offline", "Offline"], + ["available", "offline", "Offline"], + ["connected", "working", "Working"], + [undefined, "working", "Working"], + ] as const)( + "presents cached running work with connection phase %s", + (connectionPhase, status, label) => { + const thread = { ...baseThread, connectionPhase }; + expect(resolveSidebarThreadStatus(thread)).toBe(status); + expect(resolveThreadStatusPill({ thread })).toMatchObject({ + label, + pulse: status === "working", + }); + expect(thread.runtime.status).toBe("running"); + }, + ); + + it("keeps cached requests and terminal outcomes when the connection is lost", () => { + const thread = { ...baseThread, connectionPhase: "reconnecting" as const }; + expect( + resolveThreadStatusPill({ thread: { ...thread, hasPendingApprovals: true } })?.label, + ).toBe("Pending Approval"); + expect( + resolveThreadStatusPill({ thread: { ...thread, hasPendingUserInput: true } })?.label, + ).toBe("Awaiting Input"); + for (const status of ["failed", "completed", "cancelled", "interrupted", "idle"] as const) { + const online = { + ...baseThread, + runtime: { ...baseThread.runtime, status, activeRunId: null }, + }; + const disconnected = { ...online, connectionPhase: "reconnecting" as const }; + expect(resolveSidebarThreadStatus(disconnected)).toBe(resolveSidebarThreadStatus(online)); + expect(resolveThreadStatusPill({ thread: disconnected })).toEqual( + resolveThreadStatusPill({ thread: online }), + ); + } + }); + + it.each(["preparing", "queued", "starting", "running", "waiting"] as const)( + "qualifies a cached %s runtime while reconnecting", + (status) => { + const thread = { + ...baseThread, + connectionPhase: "reconnecting" as const, + runtime: { ...baseThread.runtime, status }, + }; + expect(resolveSidebarThreadStatus(thread)).toBe("reconnecting"); + expect(resolveThreadStatusPill({ thread })?.label).toBe("Reconnecting"); + }, + ); + + it.each(["failed", "completed"] as const)( + "preserves an unseen %s outcome while disconnected", + (status) => { + const thread = { + ...baseThread, + connectionPhase: "reconnecting" as const, + runtime: { ...baseThread.runtime, status, activeRunId: null }, + lastVisitedAt: "2026-03-09T09:00:00.000Z", + latestRun: { + runId: RunId.make("run-ended"), + status, + requestedAt: "2026-03-09T09:30:00.000Z", + startedAt: "2026-03-09T09:30:00.000Z", + completedAt: "2026-03-09T10:00:00.000Z", + assistantMessageId: null, + }, + }; + expect(resolveThreadStatusPill({ thread })?.label).toBe( + status === "failed" ? "Failed" : "Completed", + ); + }, + ); + it("shows an active thread wait without a pulse and resumes Working when it ends", () => { const blocked = { ...baseThread, blockedOnThreadWait: true }; expect(resolveSidebarThreadStatus(blocked)).toBe("waiting"); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..5dc4c42709ffc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,7 @@ -import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { + resolveThreadConnectionStatus, + resolveThreadWorkingStartedAt, +} from "@t3tools/client-runtime/state/models"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; @@ -620,6 +623,8 @@ export interface ThreadStatusPill { label: | "Working" | "Connecting" + | "Reconnecting" + | "Offline" | "Completed" | "Failed" | "Pending Approval" @@ -639,6 +644,8 @@ const THREAD_STATUS_PRIORITY: Record = { Failed: 3.5, Working: 3, Connecting: 3, + Reconnecting: 3, + Offline: 3, Waiting: 2.5, "Plan Ready": 2, Completed: 1, @@ -654,6 +661,7 @@ type ThreadStatusInput = Pick< | "interactionMode" | "latestRun" | "runtime" + | "connectionPhase" > & { lastVisitedAt?: string | null | undefined; pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined; @@ -802,6 +810,9 @@ export function resolveAdjacentThreadId(input: { } export type SidebarThreadStatus = + | "connecting" + | "reconnecting" + | "offline" | "approval" | "input" | "request" @@ -841,7 +852,10 @@ type SidebarThreadStatusInput = Pick< | "pendingGuestMessageCount" | "runtime" | "blockedOnThreadWait" ->; + | "connectionPhase" +> & { + readonly pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"]; +}; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { @@ -854,6 +868,8 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if ((thread.pendingGuestMessageCount ?? 0) > 0) { return "request"; } + const connectionStatus = resolveThreadConnectionStatus(thread); + if (connectionStatus !== null) return connectionStatus; if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } @@ -1102,6 +1118,21 @@ export function resolveThreadStatusPill(input: { }; } + const connectionStatus = resolveThreadConnectionStatus(thread); + if (connectionStatus !== null) { + return { + label: + connectionStatus === "connecting" + ? "Connecting" + : connectionStatus === "reconnecting" + ? "Reconnecting" + : "Offline", + colorClass: "text-sidebar-muted-foreground", + dotClass: "bg-sidebar-muted-foreground", + pulse: false, + }; + } + if ( (thread.runtime?.status === "running" && thread.blockedOnThreadWait !== true) || thread.runtime?.status === "waiting" diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a111126342f4e..0b3e45da96307 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -46,6 +46,7 @@ import { SquarePenIcon, TerminalIcon, Undo2Icon, + UnplugIcon, UserRoundIcon, XIcon, } from "lucide-react"; @@ -516,6 +517,7 @@ const dropVerbBadge: Record = { }; const TOP_STATUS_ICONS = { + connection: UnplugIcon, working: CircleDashedIcon, waiting: CircleDotDashedIcon, input: MessageCircleQuestionIcon, @@ -721,61 +723,72 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isSelected, }); const topStatus = - status === "working" + status === "connecting" || status === "reconnecting" || status === "offline" ? { - label: thread.goal?.status === "active" ? "Goal" : "Working", - icon: "working" as const, - className: "text-info-foreground", + label: + status === "connecting" + ? "Connecting" + : status === "reconnecting" + ? "Reconnecting" + : "Offline", + icon: "connection" as const, + className: "text-muted-foreground", } - : status === "waiting" + : status === "working" ? { - label: "Waiting", - icon: "waiting" as const, - className: "text-muted-foreground", + label: thread.goal?.status === "active" ? "Goal" : "Working", + icon: "working" as const, + className: "text-info-foreground", } - : status === "approval" + : status === "waiting" ? { - label: "Approval", - icon: "approval" as const, - className: "text-warning-foreground", + label: "Waiting", + icon: "waiting" as const, + className: "text-muted-foreground", } - : status === "input" + : status === "approval" ? { - label: "Input", - icon: "input" as const, - className: "text-indigo-600 dark:text-indigo-300", + label: "Approval", + icon: "approval" as const, + className: "text-warning-foreground", } - : status === "request" + : status === "input" ? { - label: "Request", - icon: "request" as const, - className: "text-guest-foreground", + label: "Input", + icon: "input" as const, + className: "text-indigo-600 dark:text-indigo-300", } - : status === "limited" + : status === "request" ? { - label: "Limited", - icon: "failed" as const, - className: "text-warning-foreground", + label: "Request", + icon: "request" as const, + className: "text-guest-foreground", } - : status === "failed" + : status === "limited" ? { - label: "Failed", + label: "Limited", icon: "failed" as const, - className: "text-error-foreground", + className: "text-warning-foreground", } - : isWoke + : status === "failed" ? { - label: "Woke", - icon: "woke" as const, - className: "text-warning-foreground", + label: "Failed", + icon: "failed" as const, + className: "text-error-foreground", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-success-foreground", + label: "Woke", + icon: "woke" as const, + className: "text-warning-foreground", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-success-foreground", + } + : null; const isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 9489402fc1f09..88f41c3fd2c72 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1334,6 +1334,7 @@ function modelReconciliationHarness( }); const shells = createEnvironmentThreadShellAtoms({ catalogValueAtom, + connectionPhaseAtom: () => Atom.make("connected" as const), snapshotAtom: () => snapshotAtom, }); const selectedShell = shells.threadShellAtom({ environmentId, threadId: selectedThreadId }); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 07fffa97cfbe0..9e4c223b5f28c 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -20,6 +20,7 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentShell } from "./shell"; +import { environmentPresentations } from "./presentation"; export const threadFamilies = createThreadFamilyAtoms( connectionAtomRuntime, @@ -39,6 +40,7 @@ export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( ); export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, + connectionPhaseAtom: environmentPresentations.connectionPhaseAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ac40471a853ba..a0c15df52a5aa 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -19,6 +19,7 @@ import type { import * as DateTime from "effect/DateTime"; import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; @@ -115,6 +116,34 @@ export function threadRuntimeIsActive(runtime: ThreadStructureRuntime | null | u return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status); } +export function resolveThreadConnectionStatus( + thread: Pick & { + readonly pendingBackgroundTasks?: EnvironmentThreadShell["pendingBackgroundTasks"] | undefined; + }, +): "connecting" | "reconnecting" | "offline" | null { + if ( + !threadRuntimeIsActive(thread.runtime) && + !( + thread.runtime?.status === "idle" && + backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) + ) + ) + return null; + switch (thread.connectionPhase) { + case "connecting": + return "connecting"; + case "reconnecting": + return "reconnecting"; + case "available": + case "offline": + case "error": + case "unsupported": + return "offline"; + default: + return null; + } +} + export function threadRuntimeCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean { if (runtime?.status === "queued") return runtime.activeRunId === null; return ( @@ -136,6 +165,7 @@ function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolea export interface EnvironmentThreadShell { readonly environmentId: EnvironmentId; + readonly connectionPhase?: EnvironmentConnectionPhase | undefined; readonly id: ThreadId; readonly projectId: ProjectId; readonly title: string; diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index f3ffb03b0bcfe..65f5ad6b3dc3c 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -109,6 +109,10 @@ export function createEnvironmentPresentationAtoms(input: { }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), ); + const connectionPhaseAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(presentationAtom(environmentId))?.connection.phase), + ); + let previous: ReadonlyMap = new Map(); const presentationsAtom = Atom.make((get) => { const next = new Map(); @@ -183,6 +187,7 @@ export function createEnvironmentPresentationAtoms(input: { return { presentationAtom, + connectionPhaseAtom, presentationsAtom, serverConfigsAtom, providersByEnvironmentAtom, diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index fda1054064f78..cd3626c149944 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -13,6 +13,7 @@ import { Atom, AtomRegistry } from "effect/reactivity"; import { describe, expect, it } from "vite-plus/test"; import { PrimaryConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import { isThreadWorking } from "./threadInbox.ts"; @@ -23,6 +24,9 @@ const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2"); const otherProjectId = ProjectId.make("other-project"); function makeHarness(environmentIds: ReadonlyArray = [environmentId]) { + const connectionPhaseAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make("connected"), + ); const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(v2ShellSnapshot), ); @@ -48,11 +52,66 @@ function makeHarness(environmentIds: ReadonlyArray = [environment registry: AtomRegistry.make(), snapshotAtom, catalogValueAtom, - threads: createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom }), + connectionPhaseAtom, + threads: createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom, + connectionPhaseAtom, + }), }; } describe("v2 thread shell lists", () => { + it("updates cached point and list reads on connection loss and recovery without changing runtime", () => { + const { registry, threads, snapshotAtom, connectionPhaseAtom } = makeHarness([ + environmentId, + remoteEnvironmentId, + ]); + const raw = { + ...v2ThreadShell, + status: "running" as const, + activeRunId: RunId.make("run-disconnected"), + latestRunId: RunId.make("run-disconnected"), + pinnedAt: v2Now, + }; + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [raw] }); + const ref = { environmentId, threadId: raw.id }; + const reads = Atom.make((get) => + [ + get(threads.threadShellAtom(ref)), + get(threads.quietThreadShellAtom(ref)), + ...get(threads.threadShellsAtom), + ...get(threads.quietThreadShellsAtom), + ...get(threads.navigationThreadShellsAtom), + ...get( + threads.threadShellsForProjectRefsAtom([{ environmentId, projectId: raw.projectId }]), + ), + ].filter((thread) => thread?.environmentId === environmentId), + ); + const dispose = registry.mount(reads); + const before = registry.get(threads.threadShellAtom(ref)); + const other = registry.get( + threads.threadShellAtom({ ...ref, environmentId: remoteEnvironmentId }), + ); + for (const phase of ["reconnecting", "error", "offline", "connecting", "connected"] as const) { + registry.set(connectionPhaseAtom(environmentId), phase); + expect(registry.get(reads)).toHaveLength(6); + for (const thread of registry.get(reads)) { + expect(thread).toMatchObject({ + connectionPhase: phase, + pinnedAt: DateTime.formatIso(v2Now), + }); + expect(thread?.runtime).toEqual(before?.runtime); + expect(thread?.source).toBe(raw); + } + expect( + registry.get(threads.threadShellAtom({ ...ref, environmentId: remoteEnvironmentId })), + ).toBe(other); + } + dispose(); + registry.dispose(); + }); + it("preserves ordered reference arrays when a middle thread changes", () => { const { registry, threads, snapshotAtom } = makeHarness(); const snapshot = { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 15718da1eca65..7068691455a67 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -13,6 +13,7 @@ import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; import { presentThreadShell, threadRuntimeIsActive } from "./models.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; import { arrayElementsEqual, parseProjectRefCollectionKey, @@ -144,6 +145,7 @@ function sameApartFromStreaming( ): boolean { if ( previous.environmentId !== next.environmentId || + previous.connectionPhase !== next.connectionPhase || previous.pendingSubagentRequest !== next.pendingSubagentRequest || !threadRuntimeIsActive(next.runtime) ) { @@ -169,6 +171,9 @@ function sameApartFromStreaming( export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; + readonly connectionPhaseAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; readonly snapshotAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; @@ -183,6 +188,7 @@ export function createEnvironmentThreadShellAtoms(input: { environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell, subagentRequests: ReadonlyMap, + connectionPhase: EnvironmentConnectionPhase | undefined, ) => { let byEnvironment = scopedThreads.get(thread); if (byEnvironment === undefined) { @@ -190,10 +196,10 @@ export function createEnvironmentThreadShellAtoms(input: { scopedThreads.set(thread, byEnvironment); } const request = subagentRequests.get(thread.id) ?? null; - const key = request === null ? environmentId : `${environmentId}:${request}`; + const key = `${environmentId}:${request ?? ""}:${connectionPhase ?? ""}`; let value = byEnvironment.get(key); if (value === undefined) { - value = presentThreadShell(environmentId, thread, request); + value = { ...presentThreadShell(environmentId, thread, request), connectionPhase }; byEnvironment.set(key, value); } return value; @@ -358,6 +364,7 @@ export function createEnvironmentThreadShellAtoms(input: { ref.environmentId, source, get(pendingSubagentRequestsAtom(ref.environmentId)), + get(input.connectionPhaseAtom(ref.environmentId)), ); }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); @@ -417,7 +424,12 @@ export function createEnvironmentThreadShellAtoms(input: { pending.push(childKey); if (subagentRequest(thread) !== null) { next.push( - scopedThread(environmentId, thread, get(pendingSubagentRequestsAtom(environmentId))), + scopedThread( + environmentId, + thread, + get(pendingSubagentRequestsAtom(environmentId)), + get(input.connectionPhaseAtom(environmentId)), + ), ); } } @@ -455,6 +467,7 @@ export function createEnvironmentThreadShellAtoms(input: { if (refs.length === 0) continue; const threads = get(environmentThreadIndexAtom(projectRef.environmentId)); const subagentRequests = get(pendingSubagentRequestsAtom(projectRef.environmentId)); + const connectionPhase = get(input.connectionPhaseAtom(projectRef.environmentId)); for (const ref of refs) { const key = threadKey(ref); if (seen.has(key)) { @@ -463,7 +476,7 @@ export function createEnvironmentThreadShellAtoms(input: { seen.add(key); const thread = threads.get(ref.threadId); if (thread !== undefined) { - next.push(scopedThread(ref.environmentId, thread, subagentRequests)); + next.push(scopedThread(ref.environmentId, thread, subagentRequests, connectionPhase)); } } } @@ -493,8 +506,9 @@ export function createEnvironmentThreadShellAtoms(input: { const next: EnvironmentThreadShell[] = []; for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); + const connectionPhase = get(input.connectionPhaseAtom(environmentId)); for (const thread of get(environmentThreadsAtom(environmentId))) { - next.push(scopedThread(environmentId, thread, subagentRequests)); + next.push(scopedThread(environmentId, thread, subagentRequests, connectionPhase)); } } if (arrayElementsEqual(previousThreadShells, next)) { @@ -552,6 +566,7 @@ export function createEnvironmentThreadShellAtoms(input: { const underParent = get(delegatedUnderParentAtom); for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); + const connectionPhase = get(input.connectionPhaseAtom(environmentId)); for (const thread of get(environmentThreadsAtom(environmentId))) { if ( thread.archivedAt !== null || @@ -559,7 +574,7 @@ export function createEnvironmentThreadShellAtoms(input: { ) { continue; } - next.push(scopedThread(environmentId, thread, subagentRequests)); + next.push(scopedThread(environmentId, thread, subagentRequests, connectionPhase)); } } if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells; From 0c54bd5438b9a297d1cfff9f7681db99b965dc92 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Thu, 29 Oct 2026 06:09:50 +0000 Subject: [PATCH 2/4] test(client): drop reconnecting cases the phase tables already cover Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/threadListV2.test.ts | 15 --------------- apps/web/src/components/Sidebar.logic.test.ts | 9 --------- 2 files changed, 24 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index e28d04dbcafc7..a47ae74a4097f 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -137,21 +137,6 @@ describe("resolveThreadListV2Status", () => { }, ); - it("does not claim live work while the environment is reconnecting", () => { - const thread = { - ...presentThreadShell( - environmentId, - makeRawThreadShell({ - status: "running", - activeRunId: RunId.make("run-disconnected"), - latestRunId: RunId.make("run-disconnected"), - }), - ), - connectionPhase: "reconnecting" as const, - }; - expect(resolveThreadListV2Status(thread)).toBe("reconnecting"); - }); - it.each([ ["connecting", "connecting"], ["reconnecting", "reconnecting"], diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3ce6618f72c6c..db5cc5d89c3fe 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -442,15 +442,6 @@ describe("resolveThreadStatusPill", () => { }, }; - it("does not claim live work while the environment is reconnecting", () => { - const thread = { ...baseThread, connectionPhase: "reconnecting" as const }; - expect(resolveSidebarThreadStatus(thread)).toBe("reconnecting"); - expect(resolveThreadStatusPill({ thread })).toMatchObject({ - label: "Reconnecting", - pulse: false, - }); - }); - it.each([ ["connecting", "connecting", "Connecting"], ["reconnecting", "reconnecting", "Reconnecting"], From d89521dbad93dd4d59a60a2d2324de16c47278e5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 02:41:55 +0000 Subject: [PATCH 3/4] refactor(web): keep connection status outside run status presentation --- .../components/sidebar/SidebarThreadRow.tsx | 98 ++++++++++--------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index b54e996a8dc62..699f76f5ecd51 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -722,73 +722,75 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, isSelected, }); - const topStatus = - status === "connecting" || status === "reconnecting" || status === "offline" + const runStatus = + status === "working" ? { - label: - status === "connecting" - ? "Connecting" - : status === "reconnecting" - ? "Reconnecting" - : "Offline", - icon: "connection" as const, - className: "text-muted-foreground", + label: thread.goal?.status === "active" ? "Goal" : "Working", + icon: "working" as const, + className: "text-info-foreground", } - : status === "working" + : status === "waiting" ? { - label: thread.goal?.status === "active" ? "Goal" : "Working", - icon: "working" as const, - className: "text-info-foreground", + label: "Waiting", + icon: "waiting" as const, + className: "text-muted-foreground", } - : status === "waiting" + : status === "approval" ? { - label: "Waiting", - icon: "waiting" as const, - className: "text-muted-foreground", + label: "Approval", + icon: "approval" as const, + className: "text-warning-foreground", } - : status === "approval" + : status === "input" ? { - label: "Approval", - icon: "approval" as const, - className: "text-warning-foreground", + label: "Input", + icon: "input" as const, + className: "text-indigo-600 dark:text-indigo-300", } - : status === "input" + : status === "request" ? { - label: "Input", - icon: "input" as const, - className: "text-indigo-600 dark:text-indigo-300", + label: "Request", + icon: "request" as const, + className: "text-guest-foreground", } - : status === "request" + : status === "limited" ? { - label: "Request", - icon: "request" as const, - className: "text-guest-foreground", + label: "Limited", + icon: "failed" as const, + className: "text-warning-foreground", } - : status === "limited" + : status === "failed" ? { - label: "Limited", + label: "Failed", icon: "failed" as const, - className: "text-warning-foreground", + className: "text-error-foreground", } - : status === "failed" + : isWoke ? { - label: "Failed", - icon: "failed" as const, - className: "text-error-foreground", + label: "Woke", + icon: "woke" as const, + className: "text-warning-foreground", } - : isWoke + : isUnread ? { - label: "Woke", - icon: "woke" as const, - className: "text-warning-foreground", + label: "Done", + icon: "done" as const, + className: "text-success-foreground", } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-success-foreground", - } - : null; + : null; + const topStatus = + status === "connecting" || status === "reconnecting" || status === "offline" + ? { + label: + status === "connecting" + ? "Connecting" + : status === "reconnecting" + ? "Reconnecting" + : "Offline", + icon: "connection" as const, + className: "text-muted-foreground", + } + : runStatus; const isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ From 66eb5acb0d92d02b7bcd35e2ac77b88c6e2efdd4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 01:17:14 +0000 Subject: [PATCH 4/4] fix(client): pass connection phase to side chat shells --- packages/client-runtime/src/state/threadShell.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 0730ce31411b8..3811bc32d6ae7 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -518,7 +518,12 @@ export function createEnvironmentThreadShellAtoms(input: { } const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId)); const next = sideChats.map((thread) => - scopedThread(ref.environmentId, thread, subagentRequests), + scopedThread( + ref.environmentId, + thread, + subagentRequests, + get(input.connectionPhaseAtom(ref.environmentId)), + ), ); if ( previous.length === next.length &&