Skip to content
Open
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
18 changes: 18 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ interface StatusLabel {
readonly iconTintClassName: string;
}
const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> = {
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",
Expand Down
82 changes: 82 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,74 @@ 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.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",
Expand Down Expand Up @@ -1625,6 +1693,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));
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +65,9 @@ export function resolveThreadListV2ProviderDrivers(
}

export type ThreadListV2Status =
| "connecting"
| "reconnecting"
| "offline"
| "approval"
| "input"
| "request"
Expand Down Expand Up @@ -228,6 +234,8 @@ export function resolveThreadListV2Status(
| "pendingGuestMessageCount"
| "runtime"
| "blockedOnThreadWait"
| "connectionPhase"
| "pendingBackgroundTasks"
>,
): ThreadListV2Status {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
Expand All @@ -240,6 +248,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";
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/state/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -42,6 +43,7 @@ export const environmentThreadDetails = createEnvironmentThreadDetailAtoms(
);
export const environmentThreadShells = createEnvironmentThreadShellAtoms({
catalogValueAtom: environmentCatalog.catalogValueAtom,
connectionPhaseAtom: environmentPresentations.connectionPhaseAtom,
snapshotAtom: threadEnvironment.snapshotAtom,
});
export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,85 @@ describe("resolveThreadStatusPill", () => {
},
};

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");
Expand Down
33 changes: 31 additions & 2 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
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";
Expand Down Expand Up @@ -666,6 +669,8 @@ export interface ThreadStatusPill {
label:
| "Working"
| "Connecting"
| "Reconnecting"
| "Offline"
| "Completed"
| "Failed"
| "Pending Approval"
Expand All @@ -688,6 +693,7 @@ type ThreadStatusInput = Pick<
| "interactionMode"
| "latestRun"
| "runtime"
| "connectionPhase"
> & {
lastVisitedAt?: string | null | undefined;
pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;
Expand Down Expand Up @@ -838,6 +844,9 @@ export function resolveAdjacentThreadId<T>(input: {
}

export type SidebarThreadStatus =
| "connecting"
| "reconnecting"
| "offline"
| "approval"
| "input"
| "request"
Expand Down Expand Up @@ -877,7 +886,10 @@ type SidebarThreadStatusInput = Pick<
| "pendingGuestMessageCount"
| "runtime"
| "blockedOnThreadWait"
>;
| "connectionPhase"
> & {
readonly pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"];
};

export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
Expand All @@ -890,6 +902,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";
}
Expand Down Expand Up @@ -1138,6 +1152,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"
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
SquarePenIcon,
TerminalIcon,
Undo2Icon,
UnplugIcon,
UserRoundIcon,
XIcon,
} from "lucide-react";
Expand Down Expand Up @@ -517,6 +518,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
};

const TOP_STATUS_ICONS = {
connection: UnplugIcon,
working: CircleDashedIcon,
waiting: CircleDotDashedIcon,
input: MessageCircleQuestionIcon,
Expand Down Expand Up @@ -721,7 +723,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
isActive: props.isActive,
isSelected,
});
const topStatus =
const runStatus =
status === "working"
? {
label: thread.goal?.status === "active" ? "Goal" : "Working",
Expand Down Expand Up @@ -777,6 +779,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
className: "text-success-foreground",
}
: 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({
Expand Down
Loading
Loading