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
27 changes: 27 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import * as DateTime from "effect/DateTime";
import * as Schema from "effect/Schema";
import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
import {
createPendingThreadOrder,
Expand All @@ -17,6 +18,7 @@ import {
CommandId,
EnvironmentId,
MessageId,
OrchestrationV2ThreadShell,
ProjectId,
ProviderInstanceId,
RunId,
Expand All @@ -43,6 +45,7 @@ import {
} from "./threadListV2";

const environmentId = EnvironmentId.make("environment-1");
const decodeThreadShell = Schema.decodeUnknownSync(OrchestrationV2ThreadShell);

function makeThread(
input: Partial<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">,
Expand Down Expand Up @@ -111,6 +114,30 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
});

describe("resolveThreadListV2Status", () => {
it("presents decoded thread waits as Waiting while preserving requests and older servers", () => {
const raw = makeRawThreadShell({
status: "running",
activeRunId: RunId.make("run-wait"),
latestRunId: RunId.make("run-wait"),
});
const present = (shell: unknown) => presentThreadShell(environmentId, decodeThreadShell(shell));

expect(resolveThreadListV2Status(present(raw))).toBe("working");
const blocked = present({ ...raw, blockedOnThreadWait: true });
expect(
resolveThreadListV2Status({
...blocked,
hasPendingApprovals: true,
hasPendingUserInput: true,
}),
).toBe("approval");
expect(resolveThreadListV2Status({ ...blocked, hasPendingUserInput: true })).toBe("input");
expect(resolveThreadListV2Status(blocked)).toBe("waiting");
expect(resolveThreadListV2Status(present({ ...raw, blockedOnThreadWait: false }))).toBe(
"working",
);
});

it("distinguishes usage limits from ordinary failures and clears the label after recovery", () => {
const thread = makeThread({
id: ThreadId.make("limited"),
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ export function threadHasUnseenCompletion(
export function resolveThreadListV2Status(
thread: Pick<
EnvironmentThreadShell,
"hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
| "hasPendingApprovals"
| "hasPendingUserInput"
| "pendingSubagentRequest"
| "runtime"
| "blockedOnThreadWait"
>,
): ThreadListV2Status {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
Expand All @@ -163,6 +167,9 @@ export function resolveThreadListV2Status(
if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
return "input";
}
if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) {
return "waiting";
}
if (
thread.runtime !== null &&
["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
Expand Down
42 changes: 36 additions & 6 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,20 +935,31 @@ describe("resolveSidebarThreadStatus", () => {
const idle = { hasPendingApprovals: false, hasPendingUserInput: false, runtime: null };

it("prioritizes approval over a running runtime", () => {
expect(resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, runtime })).toBe(
"approval",
);
expect(
resolveSidebarThreadStatus({
...idle,
hasPendingApprovals: true,
runtime,
blockedOnThreadWait: true,
}),
).toBe("approval");
});

it("prioritizes awaiting input over a running runtime, below approval", () => {
expect(resolveSidebarThreadStatus({ ...idle, hasPendingUserInput: true, runtime })).toBe(
"input",
);
expect(
resolveSidebarThreadStatus({
...idle,
hasPendingUserInput: true,
runtime,
blockedOnThreadWait: true,
}),
).toBe("input");
expect(
resolveSidebarThreadStatus({
...idle,
hasPendingApprovals: true,
hasPendingUserInput: true,
blockedOnThreadWait: true,
runtime,
}),
).toBe("approval");
Expand All @@ -960,6 +971,7 @@ describe("resolveSidebarThreadStatus", () => {
resolveSidebarThreadStatus({
...idle,
runtime: { ...runtime, status: "starting" as const },
blockedOnThreadWait: true,
}),
).toBe("working");
});
Expand Down Expand Up @@ -1283,6 +1295,7 @@ describe("resolveThreadStatusPill", () => {
...baseThread,
hasPendingApprovals: true,
hasPendingUserInput: true,
blockedOnThreadWait: true,
},
}),
).toMatchObject({ label: "Pending Approval", pulse: false });
Expand All @@ -1294,6 +1307,7 @@ describe("resolveThreadStatusPill", () => {
thread: {
...baseThread,
hasPendingUserInput: true,
blockedOnThreadWait: true,
},
}),
).toMatchObject({ label: "Awaiting Input", pulse: false });
Expand All @@ -1307,6 +1321,22 @@ describe("resolveThreadStatusPill", () => {
).toMatchObject({ label: "Working", pulse: true });
});

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");
expect(resolveThreadStatusPill({ thread: blocked })).toMatchObject({
label: "Waiting",
pulse: false,
});

const resumed = { ...blocked, blockedOnThreadWait: false };
expect(resolveSidebarThreadStatus(resumed)).toBe("working");
expect(resolveThreadStatusPill({ thread: resumed })).toMatchObject({
label: "Working",
pulse: true,
});
});

it("shows waiting for an idle thread with pending background tasks", () => {
expect(
resolveThreadStatusPill({
Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ type ThreadStatusInput = Pick<
> & {
lastVisitedAt?: string | null | undefined;
pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;
blockedOnThreadWait?: boolean | undefined;
};

export interface ThreadJumpHintVisibilityController {
Expand Down Expand Up @@ -902,7 +903,11 @@ export function shouldRecedeSidebarThread(input: {

type SidebarThreadStatusInput = Pick<
SidebarThreadSummary,
"hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
| "hasPendingApprovals"
| "hasPendingUserInput"
| "pendingSubagentRequest"
| "runtime"
| "blockedOnThreadWait"
>;

export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
Expand All @@ -912,6 +917,9 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si
if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
return "input";
}
if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) {
return "waiting";
}
if (
thread.runtime !== null &&
["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
Expand Down Expand Up @@ -1104,7 +1112,10 @@ export function resolveThreadStatusPill(input: {
};
}

if (thread.runtime?.status === "running" || thread.runtime?.status === "waiting") {
if (
(thread.runtime?.status === "running" && thread.blockedOnThreadWait !== true) ||
thread.runtime?.status === "waiting"
) {
return {
label: "Working",
colorClass: "text-sky-600 dark:text-sky-300/80",
Expand All @@ -1126,7 +1137,10 @@ export function resolveThreadStatusPill(input: {
};
}

if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) {
if (
(thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) ||
backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
) {
return {
label: "Waiting",
colorClass: "text-sidebar-muted-foreground",
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/state/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export interface EnvironmentThreadShell {
readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"];
readonly latestRun: ThreadRunSummary | null;
readonly runtime: ThreadRuntimeSummary | null;
readonly blockedOnThreadWait?: boolean;
readonly latestUserMessageAt: string | null;
readonly latestUserAuthoredMessageAt?: string | null;
readonly hasPendingApprovals: boolean;
Expand Down Expand Up @@ -248,6 +249,7 @@ export function presentThreadShell(
activeProviderThreadId: thread.activeProviderThreadId,
latestRun,
runtime,
blockedOnThreadWait: thread.blockedOnThreadWait ?? false,
latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
...(thread.latestUserAuthoredMessageAt === undefined
? {}
Expand Down
2 changes: 1 addition & 1 deletion packages/client-runtime/src/t3ToolSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function readResult(value: unknown, depth = 0): ToolResult {

function readInput(value: unknown): Record<string, unknown> | undefined {
const input = readResult(value).data;
// Cursor retains its MCP args envelope; other adapters retain the arguments directly.
// Historical Cursor items retain the MCP args envelope.
return input && typeof input.toolName === "string" ? asRecord(input.args) : input;
}

Expand Down
Loading