Skip to content
Closed
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
14 changes: 12 additions & 2 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> =
className: "text-adaptive-sky-600-400",
iconTintClassName: "accent-adaptive-sky-600-400",
},
pending: {
label: "Pending",
icon: "tray.and.arrow.up",
className: "text-foreground-muted",
iconTintClassName: "accent-foreground-muted",
},
waiting: {
label: "Waiting",
icon: "circle.dashed.inset.filled",
Expand Down Expand Up @@ -736,7 +742,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const selected = props.selected === true;
const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);

const status = resolveThreadListV2Status(thread);
const status = resolveThreadListV2Status(thread, {
hasQueuedMessages: props.hasQueuedMessages === true,
});
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const showsWoke = status === "ready" && props.wokeAt !== null;
Expand Down Expand Up @@ -1164,7 +1172,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{thread.title}
</Text>
<View className="flex-row items-center gap-1.5" style={{ minHeight: titleLineHeight }}>
{props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
{props.hasQueuedMessages && status !== "pending" ? (
<QueuedMessageIcon selected={selected} />
) : null}
{pinnedRow ? (
<SymbolView
name="pin"
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ describe("resolveThreadListV2Status", () => {
expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval");
});

it("treats an outbox pending message as Pending, after attention requests", () => {
const thread = presentThreadShell(
environmentId,
decodeThreadShell(makeRawThreadShell({ status: "completed" })),
);
expect(resolveThreadListV2Status(thread, { hasQueuedMessages: true })).toBe("pending");
expect(
resolveThreadListV2Status(
{ ...thread, hasPendingUserInput: true },
{ hasQueuedMessages: true },
),
).toBe("input");
expect(
resolveThreadListV2Status(
{ ...thread, pendingGuestMessageCount: 1 },
{ hasQueuedMessages: true },
),
).toBe("request");
});

it.each([
{ roster: "dev server", task: { kind: "command" }, status: "ready" },
{ roster: "dev server watchdog", task: { kind: "monitor" }, status: "ready" },
Expand Down Expand Up @@ -1601,6 +1621,7 @@ describe("buildThreadListV2ListItems row-state stamps", () => {
const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!;
const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!;
expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true);
expect(readyQueued.type === "v2-thread" && readyQueued.timeLabel).toBe("");
expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false);
expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false);
expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true);
Expand Down Expand Up @@ -1825,4 +1846,85 @@ describe("queued messages keep a settled thread active", () => {
]);
expect(layout.settledCount).toBe(1);
});

it("counts a reconnect send as working when the Working shelf is on", () => {
const layout = buildThreadListV2Items({
threads: [
...threads,
makeThread({ id: ThreadId.make("ready-queued"), title: "Ready with outbox" }),
],
environmentId: null,
searchQuery: "",
now: NOW,
queuedThreadKeys: new Set([...queuedThreadKeys, `${environmentId}:ready-queued`]),
workingShelfEnabled: true,
workingShelfExpanded: true,
});
expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([
["active", "card"],
["ready-queued", "card"],
["settled-queued", "card"],
["settled", "slim"],
]);
expect(layout.workingCount).toBe(2);
expect(layout.workingShelfHeaderIndex).toBe(1);
expect(layout.settledCount).toBe(1);
});

it("keeps a queued thread with an attention request in Active", () => {
const running = {
status: "running" as const,
activeRunId: null,
providerInstanceId: ProviderInstanceId.make("codex"),
providerName: "Codex",
lastError: null,
updatedAt: NOW,
};
const layout = buildThreadListV2Items({
threads: [
makeThread({
id: ThreadId.make("queued-approval"),
title: "Queued with approval",
hasPendingApprovals: true,
}),
makeThread({
id: ThreadId.make("queued-guest"),
title: "Queued with guest request",
runtime: running,
pendingGuestMessageCount: 1,
}),
makeThread({
id: ThreadId.make("queued-plan"),
title: "Queued with proposed plan",
interactionMode: "plan",
hasActionableProposedPlan: true,
latestRun: {
runId: RunId.make("queued-plan-run"),
status: "completed",
requestedAt: NOW,
startedAt: NOW,
completedAt: NOW,
assistantMessageId: null,
},
}),
makeThread({ id: ThreadId.make("ready-queued"), title: "Ready with outbox" }),
],
environmentId: null,
searchQuery: "",
now: NOW,
queuedThreadKeys: new Set([
`${environmentId}:queued-approval`,
`${environmentId}:queued-guest`,
`${environmentId}:queued-plan`,
`${environmentId}:ready-queued`,
]),
workingShelfEnabled: true,
workingShelfExpanded: true,
});
const ids = layout.items.map((item) => item.thread.id);
expect(ids.slice(0, 3).sort()).toEqual(["queued-approval", "queued-guest", "queued-plan"]);
expect(ids.slice(3)).toEqual(["ready-queued"]);
expect(layout.workingCount).toBe(1);
expect(layout.workingShelfHeaderIndex).toBe(3);
});
});
45 changes: 40 additions & 5 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type ThreadListV2Status =
| "input"
| "request"
| "working"
| "pending"
| "waiting"
| "failed"
| "limited"
Expand Down Expand Up @@ -267,6 +268,7 @@ export function resolveThreadListV2Status(
| "runtime"
| "blockedOnThreadWait"
>,
options?: { readonly hasQueuedMessages?: boolean },
): ThreadListV2Status {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
return "approval";
Expand All @@ -287,6 +289,9 @@ export function resolveThreadListV2Status(
) {
return "working";
}
if (options?.hasQueuedMessages === true) {
return "pending";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (thread.runtime?.status === "idle") {
return "waiting";
}
Expand Down Expand Up @@ -501,12 +506,13 @@ export function threadListV2ListItemsAreEqual(
function resolveThreadListV2ItemTimeLabel(
item: ThreadListV2Item,
showSnoozeWakeLabel: boolean,
options?: { readonly hasQueuedMessages?: boolean },
): string {
const { thread, variant, snoozed } = item;
if (showSnoozeWakeLabel) return "";
if (
variant === "card" &&
(resolveThreadListV2Status(thread) !== "ready" ||
(resolveThreadListV2Status(thread, options) !== "ready" ||
item.wokeAt !== null ||
threadHasUnseenCompletion(thread))
)
Expand Down Expand Up @@ -554,16 +560,19 @@ export function buildThreadListV2ListItems(input: {
item.variant === "card"
? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`)
: undefined;
const hasQueuedMessages =
queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true;
return {
type: "v2-thread",
key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
item,
snoozeWakeLabelText,
timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined),
timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined, {
hasQueuedMessages,
}),
snoozePresetMinute,
showTrailingDivider: false,
hasQueuedMessages:
queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true,
hasQueuedMessages,
canMoveUp: move?.canMoveUp === true,
canMoveDown: move?.canMoveDown === true,
};
Expand Down Expand Up @@ -748,11 +757,37 @@ export function buildThreadListV2Items(input: {
}
const hasQueuedMessages =
queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true;
// Keep attention visible in Active when Working is collapsed. Guest requests are
// also blocked on both Working paths while a send is queued, since isThreadWorking
// can still be true for a running guest-request thread.
const hasGuestRequest = (thread.pendingGuestMessageCount ?? 0) > 0;
const hasAttentionRequest =
thread.hasPendingApprovals ||
thread.hasPendingUserInput ||
thread.pendingSubagentRequest != null ||
hasGuestRequest;
const run = thread.latestRun;
const runSettled =
run !== null &&
run.status !== "preparing" &&
run.status !== "queued" &&
run.status !== "starting" &&
run.status !== "running" &&
run.status !== "waiting" &&
thread.runtime?.activeRunId !== run.runId;
const hasActionablePlanReview =
thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled;
const countsQueuedAsWorking =
hasQueuedMessages && !hasAttentionRequest && !hasActionablePlanReview;
if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) {
settled.push(thread);
} else if (thread.pinnedAt != null) {
pinned.push(thread);
} else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) {
} else if (
workingShelfEnabled &&
!(hasQueuedMessages && hasGuestRequest) &&
(countsQueuedAsWorking || (input.isWorking ?? isThreadWorking)(thread))
) {
Comment thread
wSedlacek marked this conversation as resolved.
Resolved
working.push(thread);
} else {
active.push(thread);
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,15 @@ it("reuses a large thread list across tab renders and updates when its inputs ch
).toBe(true);

const queuedThreadKeys = new Set([`${environmentId}:thread-150`]);
await act(async () => root.render(<Probe input={{ ...input, queuedThreadKeys }} tab="chats" />));
await act(async () =>
root.render(
<Probe input={{ ...input, queuedThreadKeys, workingShelfExpanded: true }} tab="chats" />,
),
);
const queued = seen.at(-1)!;
expect(queued.layout.items).toHaveLength(161);
expect(queued.layout.settledCount).toBe(1049);
expect(queued.layout.workingCount).toBe(1);
expect(
queued.items.find((item) => item.type === "v2-thread" && item.item.thread.id === "thread-150"),
).toMatchObject({ hasQueuedMessages: true, item: { variant: "card" } });
Expand Down
Loading