Skip to content
3 changes: 2 additions & 1 deletion apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ThreadMoveDestination } from "../threads/threadOrder";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadHasOpenQuestion } from "@t3tools/client-runtime/state/models";
import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo";
import { showThreadUndoNotice } from "./thread-undo-notice";
import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
Expand Down Expand Up @@ -342,7 +343,7 @@ export function useThreadListActions(): {
Dialog.alert(
"Could not snooze thread",
thread.hasPendingApprovals ||
thread.hasPendingUserInput ||
threadHasOpenQuestion(thread) ||
thread.pendingSubagentRequest != null
? "This thread is waiting on you. Respond to the pending request before snoozing it."
: "This thread is still starting a turn. Try again once it's running.",
Expand Down
40 changes: 34 additions & 6 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state
import {
formatWorkingDurationLabel,
resolveThreadWorkingStartedAt,
threadWorksPastQuestion,
} from "@t3tools/client-runtime/state/models";
import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
Expand Down Expand Up @@ -82,6 +83,12 @@ interface StatusLabel {
readonly className: string;
readonly iconTintClassName: string;
}
const WORKING_STATUS_LABEL: StatusLabel = {
label: "Working",
icon: "circle.dashed",
className: "text-adaptive-sky-600-400",
iconTintClassName: "accent-adaptive-sky-600-400",
};
const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> = {
approval: {
label: "Approval",
Expand All @@ -101,12 +108,7 @@ const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> =
className: "text-adaptive-fuchsia-600-300",
iconTintClassName: "accent-adaptive-fuchsia-600-300",
},
working: {
label: "Working",
icon: "circle.dashed",
className: "text-adaptive-sky-600-400",
iconTintClassName: "accent-adaptive-sky-600-400",
},
working: WORKING_STATUS_LABEL,
waiting: {
label: "Waiting",
icon: "circle.dashed.inset.filled",
Expand Down Expand Up @@ -680,6 +682,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);

const status = resolveThreadListV2Status(thread);
const worksPastQuestion = status === "input" && threadWorksPastQuestion(thread);
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const showsWoke = status === "ready" && props.wokeAt !== null;
Expand Down Expand Up @@ -1116,6 +1119,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<ThreadListV2Duration startedAt={thread.latestRun?.completedAt ?? null} />
) : null}
</Text>
{worksPastQuestion ? (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<>
<SymbolView
name={WORKING_STATUS_LABEL.icon}
size={13}
tintColorClassName={
selected
? selectedThreadRowColors.iconTintClassName
: WORKING_STATUS_LABEL.iconTintClassName
}
type="monochrome"
weight="semibold"
/>
<Text
className={cn(
"text-xs font-t3-bold tabular-nums",
selected
? selectedThreadRowColors.foregroundClassName
: WORKING_STATUS_LABEL.className,
)}
>
<ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} />
</Text>
</>
) : null}
</View>
) : (
<Text
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ProjectId,
ProviderInstanceId,
RunId,
RuntimeRequestId,
ThreadId,
} from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
Expand Down Expand Up @@ -101,6 +102,26 @@ describe("resolveThreadListV2Status", () => {
);
});

it("shows Input for an async question while the agent keeps working", () => {
const thread = presentThreadShell(
environmentId,
makeRawThreadShell({
status: "running",
activeRunId: RunId.make("run-async-question"),
latestRunId: RunId.make("run-async-question"),
pendingRuntimeRequest: {
id: RuntimeRequestId.make("request-async-question"),
kind: "user_input",
createdAt: DateTime.makeUnsafe(NOW),
blocking: false,
},
}),
);

expect(thread.hasPendingUserInput).toBe(false);
expect(resolveThreadListV2Status(thread)).toBe("input");
});

it("shows a waiting guest message as Request, after the agent's own requests", () => {
const thread = presentThreadShell(
environmentId,
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,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 {
resolveThreadProviderStack,
threadHasOpenQuestion,
} from "@t3tools/client-runtime/state/models";
import {
threadSearchMatchKey,
threadSearchSubject,
Expand Down Expand Up @@ -193,12 +196,14 @@ export function resolveThreadListV2Status(
| "pendingGuestMessageCount"
| "runtime"
| "blockedOnThreadWait"
>,
> & {
readonly source?: Pick<EnvironmentThreadShell["source"], "pendingRuntimeRequest">;
},
): ThreadListV2Status {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
return "approval";
}
if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") {
return "input";
}
// A guest is waiting on the owner; the agent's own requests come first.
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ import {
isSidebarNestedLinkClick,
isTrailingDoubleClick,
resolveProjectStatusIndicator,
threadStatusPillText,
resolveThreadLastVisitedAt,
resolveThreadStatusPill,
orderItemsByPreferredIds,
Expand Down Expand Up @@ -2302,7 +2303,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
<TooltipTrigger
render={
<span
aria-label={projectStatus.label}
aria-label={threadStatusPillText(projectStatus)}
className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
/>
}
Expand All @@ -2316,7 +2317,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
</span>
<ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-icon-muted opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
</TooltipTrigger>
<TooltipPopup side="top">{projectStatus.label}</TooltipPopup>
<TooltipPopup side="top">{threadStatusPillText(projectStatus)}</TooltipPopup>
</Tooltip>
) : (
<ChevronRightIcon
Expand Down
57 changes: 56 additions & 1 deletion apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ import {
type SidebarListMarker,
type SidebarSection,
resolveSidebarDropVerb,
threadStatusPillText,
} from "./Sidebar.logic";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
RunId,
RuntimeRequestId,
ThreadId,
} from "@t3tools/contracts";

import {
DEFAULT_INTERACTION_MODE,
Expand Down Expand Up @@ -442,6 +450,38 @@ describe("resolveThreadStatusPill", () => {
},
};

it("shows Input for an async question and keeps pulsing while the agent works", () => {
const question = (blocking: boolean) =>
presentThreadShell(localEnvironmentId, {
...makeThreadFixture().source,
latestRunId: RunId.make("run-question"),
activeRunId: RunId.make("run-question"),
status: "running",
pendingRuntimeRequest: {
id: RuntimeRequestId.make("request-question"),
kind: "user_input",
createdAt: DateTime.makeUnsafe("2026-03-09T10:00:00.000Z"),
blocking,
},
});
const asyncQuestion = question(false);

expect(asyncQuestion.hasPendingUserInput).toBe(false);
expect(resolveSidebarThreadStatus(asyncQuestion)).toBe("input");
expect(resolveThreadStatusPill({ thread: asyncQuestion })).toMatchObject({
label: "Awaiting Input",
pulse: true,
working: true,
});
for (const thread of [question(true), { ...asyncQuestion, blockedOnThreadWait: true }]) {
expect(resolveThreadStatusPill({ thread })).toMatchObject({
label: "Awaiting Input",
pulse: false,
working: false,
});
}
});

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 Expand Up @@ -581,6 +621,21 @@ describe("resolveThreadStatusPill", () => {
});

describe("resolveProjectStatusIndicator", () => {
it("lets a blocked question outrank one the agent keeps working past", () => {
const input = {
label: "Awaiting Input" as const,
colorClass: "text-indigo-600",
dotClass: "bg-indigo-500",
};
const blocked = { ...input, pulse: false, working: false };
const working = { ...input, pulse: true, working: true };

expect(resolveProjectStatusIndicator([blocked, working])).toBe(blocked);
expect(resolveProjectStatusIndicator([working, blocked])).toBe(blocked);
expect(threadStatusPillText(working)).toBe("Awaiting Input · Working");
expect(threadStatusPillText(blocked)).toBe("Awaiting Input");
});

it("ranks waiting below active work and above plan-ready", () => {
const waiting = {
label: "Waiting" as const,
Expand Down
34 changes: 27 additions & 7 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
import {
resolveThreadWorkingStartedAt,
threadHasOpenQuestion,
threadWorksPastQuestion,
} from "@t3tools/client-runtime/state/models";
import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
import * as React from "react";
Expand Down Expand Up @@ -634,6 +638,12 @@ export interface ThreadStatusPill {
colorClass: string;
dotClass: string;
pulse: boolean;
/** The agent keeps working past an async question. */
working?: boolean;
}

export function threadStatusPillText(status: ThreadStatusPill): string {
return status.working ? `${status.label} · Working` : status.label;
}

const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
Expand Down Expand Up @@ -662,6 +672,7 @@ type ThreadStatusInput = Pick<
lastVisitedAt?: string | null | undefined;
pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;
blockedOnThreadWait?: boolean | undefined;
source?: Pick<SidebarThreadSummary["source"], "pendingRuntimeRequest"> | undefined;
};

/**
Expand Down Expand Up @@ -866,13 +877,15 @@ type SidebarThreadStatusInput = Pick<
| "pendingGuestMessageCount"
| "runtime"
| "blockedOnThreadWait"
>;
> & {
source?: Pick<SidebarThreadSummary["source"], "pendingRuntimeRequest"> | undefined;
};

export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
return "approval";
}
if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") {
return "input";
}
// A guest is waiting on the owner; the agent's own requests come first.
Expand Down Expand Up @@ -1060,12 +1073,14 @@ export function resolveThreadStatusPill(input: {
};
}

if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const working = threadWorksPastQuestion(thread);
return {
label: "Awaiting Input",
colorClass: "text-indigo-600 dark:text-indigo-300/90",
dotClass: "bg-indigo-500 dark:bg-indigo-300/90",
pulse: false,
pulse: working,
working,
};
}

Expand Down Expand Up @@ -1157,9 +1172,14 @@ export function resolveProjectStatusIndicator(

for (const status of statuses) {
if (status === null) continue;
const priority = THREAD_STATUS_PRIORITY[status.label];
const highestPriority =
highestPriorityStatus === null ? null : THREAD_STATUS_PRIORITY[highestPriorityStatus.label];
if (
highestPriorityStatus === null ||
THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label]
highestPriority === null ||
priority > highestPriority ||
// A blocked question outranks one the agent keeps working past.
(priority === highestPriority && highestPriorityStatus?.working === true && !status.working)
) {
highestPriorityStatus = status;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox";
import { RunId, RuntimeRequestId } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
createThreadNotificationGate,
resolveThreadAttention,
type ThreadNotificationObservation,
} from "./ThreadNotificationCoordinator.logic";

Expand Down Expand Up @@ -191,3 +194,25 @@ describe("createThreadNotificationGate", () => {
expect(notify).not.toHaveBeenCalled();
});
});

describe("resolveThreadAttention", () => {
const shell = (requestId: string, runId: string) => ({
hasPendingApprovals: false,
hasPendingUserInput: false,
pendingSubagentRequest: null,
latestRun: { runId: RunId.make(runId) },
source: {
pendingRuntimeRequest: {
id: RuntimeRequestId.make(requestId),
kind: "user_input" as const,
createdAt: DateTime.makeUnsafe(0),
},
},
});

it("follows the thread's own request rather than its latest run", () => {
const question = resolveThreadAttention(shell("question", "run-1"), "input");
expect(resolveThreadAttention(shell("question", "run-2"), "input")).toBe(question);
expect(resolveThreadAttention(shell("secret", "run-1"), "input")).not.toBe(question);
});
});
Loading
Loading