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
20 changes: 19 additions & 1 deletion apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ import {
type ThreadListV2Status,
} from "./threadListV2";
import { QueuedMessageIcon } from "./queued-message-icon";
import { useThreadHasNoPendingWake } from "./use-thread-has-no-pending-wake";
import { RelativeTime } from "../../components/RelativeTime";
import { shouldRecedeThreadRow } from "./thread-row-emphasis";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
Expand Down Expand Up @@ -132,6 +134,12 @@ const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> =
iconTintClassName: "accent-warning-foreground",
},
};
const IDLE_STATUS_LABEL: StatusLabel = {
label: "Idle",
icon: "circle",
className: "text-foreground-muted",
iconTintClassName: "accent-foreground-muted",
};
const DONE_STATUS_LABEL: StatusLabel = {
label: "Done",
icon: "checkmark.circle",
Expand Down Expand Up @@ -739,10 +747,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const showsWoke = status === "ready" && props.wokeAt !== null;
// Settled and snoozed rows show the user's own decision, and queued messages will start a turn.
const isIdle = useThreadHasNoPendingWake(
thread,
variant === "card" && !snoozedRow && props.hasQueuedMessages !== true,
);
const statusLabel =
(status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
? { ...workingLabel, label: "Goal" }
: workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
: workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : isIdle ? IDLE_STATUS_LABEL : undefined);
const recede = shouldRecedeThreadRow({ status, selected });
const isRegeneratingTitle = thread.titleRegeneration != null;
const rowAccessibilityLabel = `${thread.title}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`;
Expand Down Expand Up @@ -1205,6 +1218,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} />
) : status === "waiting" ? (
<ThreadListV2Duration startedAt={thread.latestRun?.completedAt ?? null} />
) : statusLabel === IDLE_STATUS_LABEL && thread.latestRun?.completedAt ? (
<>
{" "}
<RelativeTime timestamp={thread.latestRun.completedAt} />
</>
) : null}
</Text>
</View>
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/threads/use-thread-has-no-pending-wake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useAtomValue } from "@effect/atom-react";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import { threadHasNoPendingWake } from "@t3tools/client-runtime/state/thread-inbox";
import { Atom } from "effect/reactivity";

import { serverEnvironment } from "../../state/server";

const UNKNOWN_SCHEDULED_WAKE_ATOM = Atom.make<boolean | null>(null);

/** Only threads that are otherwise idle subscribe to the scheduled task list. */
export function useThreadHasNoPendingWake(
thread: EnvironmentThreadShell,
enabled: boolean,
): boolean {
const mayBeIdle = enabled && threadHasNoPendingWake(thread, false);
const hasScheduledWake = useAtomValue(
mayBeIdle
? serverEnvironment.threadHasScheduledWake(
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
)
: UNKNOWN_SCHEDULED_WAKE_ATOM,
);
return mayBeIdle && hasScheduledWake === false;
}
12 changes: 12 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,17 @@ it.effect("SQL queue readiness skips held runs without bypassing active work", (
const { runs } = yield* store.getThreadProjection(threadId);
const run = runs[0]!;
const now = yield* DateTime.now;
const assertShellsHoldQueue = Effect.fnUntraced(function* (held: boolean) {
const memoryShell = ProjectionStore.threadShellFromProjection(
yield* store.getThreadProjection(threadId),
);
const sqlShell = (yield* store.getShellSnapshot()).threads.find(
(row) => row.id === threadId,
)!;
assert.equal(memoryShell.hasHeldQueue, held);
assert.equal(sqlShell.hasHeldQueue, held);
});
yield* assertShellsHoldQueue(false);
const held = { ...run, status: "queued" as const, queueHeld: true };
yield* store.apply({
id: EventId.make("held-queue:held"),
Expand All @@ -552,6 +563,7 @@ it.effect("SQL queue readiness skips held runs without bypassing active work", (
payload: held,
});
assert.isFalse(yield* store.canStartQueuedRun(threadId));
yield* assertShellsHoldQueue(true);

const wake = { ...run, id: RunId.make("held-queue:wake"), ordinal: 2 };
for (const status of ["running", "queued", "completed"] as const) {
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,7 @@ type ShellThreadRow = {
readonly latest_run_started_at: string | null;
readonly latest_run_completed_at: string | null;
readonly latest_run_stop_requested: number;
readonly has_held_queue: number;
readonly active_run_id: string | null;
readonly active_run_status: string | null;
readonly activity_run_status: string | null;
Expand Down Expand Up @@ -1564,6 +1565,7 @@ export function threadShellFromProjection(
projection.turnItems.some(
(item) => item.runId === latestRun.id && item.type === "run_interrupt_request",
),
hasHeldQueue: runs.some((run) => run.status === "queued" && run.queueHeld === true),
activeRunId: activeRun?.id ?? null,
blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }),
activityRunStatus: activityRun?.status ?? null,
Expand Down Expand Up @@ -1681,6 +1683,7 @@ type ShellThreadState = {
readonly latestRunStartedAt: DateTime.Utc | null;
readonly latestRunCompletedAt: DateTime.Utc | null;
readonly latestRunStopRequested: boolean;
readonly hasHeldQueue: boolean;
readonly activeRunId: RunId | null;
readonly blockedOnThreadWait: boolean;
readonly activityRunStatus: ShellActivityRunStatus | null;
Expand Down Expand Up @@ -1841,6 +1844,7 @@ function shellFromState(input: {
latestRunStartedAt: input.state.latestRunStartedAt,
latestRunCompletedAt: input.state.latestRunCompletedAt,
latestRunStopRequested: input.state.latestRunStopRequested,
hasHeldQueue: input.state.hasHeldQueue,
activeRunId: input.state.activeRunId,
blockedOnThreadWait: input.state.blockedOnThreadWait,
activityRunStatus: input.state.activityRunStatus,
Expand Down Expand Up @@ -6011,6 +6015,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id
AND stop_request.type = 'run_interrupt_request'
) AS latest_run_stop_requested,
EXISTS (
SELECT 1 FROM orchestration_v2_projection_runs held
WHERE held.thread_id = t.thread_id AND held.status = 'queued'
AND json_extract(held.payload_json, '$.queueHeld') IS 1
AND NOT ${isSilentShellRun("held")}
) AS has_held_queue,
active.run_id AS active_run_id,
active.status AS active_run_status,
(
Expand Down Expand Up @@ -6681,6 +6691,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
latestRunStartedAt,
latestRunCompletedAt,
latestRunStopRequested,
hasHeldQueue: row.has_held_queue === 1,
activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id),
blockedOnThreadWait: deriveBlockedOnThreadWait({
activeRun:
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
deleteSelectedThreadEntries,
getFallbackThreadIdAfterDelete,
hasUnseenCompletion,
threadHeaderMayShowIdle,
resolveSidebarRowThreadId,
belongsInSidebarWorkingShelf,
isSidebarEnvironmentDisconnected,
Expand Down Expand Up @@ -44,7 +45,14 @@ import {
} from "@t3tools/client-runtime/state/thread-sort";
import { resolveProjectExpanded } from "../uiStateStore";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
RunId,
ThreadId,
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";

import {
DEFAULT_INTERACTION_MODE,
Expand Down Expand Up @@ -866,6 +874,38 @@ describe("navigation after parking a thread", () => {
});

describe("unseen completion with background work", () => {
it("lets the header show Idle only where the sidebar row would", () => {
const now = "2026-06-20T02:00:00.000Z";
const read = "2026-06-20T01:30:00.000Z";
const ended = (overrides: Partial<OrchestrationV2ThreadShell> = {}) =>
presentThreadShell(localEnvironmentId, {
...makeThreadFixture().source,
latestRunId: RunId.make("run-header-idle"),
status: "completed",
latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
...overrides,
});
expect(threadHeaderMayShowIdle(ended(), { lastVisitedAt: read, now })).toBe(true);
// Done comes first while the completion is unread.
const unread = { lastVisitedAt: "2026-06-20T00:30:00.000Z", now };
expect(threadHeaderMayShowIdle(ended(), unread)).toBe(false);
const settled = ended({ settledOverride: "settled" });
expect(threadHeaderMayShowIdle(settled, { lastVisitedAt: read, now })).toBe(false);
const snoozed = (until: string) =>
ended({
snoozedAt: DateTime.makeUnsafe("2026-06-20T01:35:00.000Z"),
snoozedUntil: DateTime.makeUnsafe(until),
});
expect(
threadHeaderMayShowIdle(snoozed("2026-06-20T03:00:00.000Z"), { lastVisitedAt: read, now }),
).toBe(false);
// Woke comes first until the user opens the thread after it woke.
const woke = snoozed("2026-06-20T01:50:00.000Z");
expect(threadHeaderMayShowIdle(woke, { lastVisitedAt: read, now })).toBe(false);
const seen = { lastVisitedAt: "2026-06-20T01:55:00.000Z", now };
expect(threadHeaderMayShowIdle(woke, seen)).toBe(true);
});

it.each([
{
roster: "dev server",
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/c
import type { AsyncResult } from "effect/reactivity";
import {
effectiveSnoozed,
isThreadListedActive,
threadWokeAt,
type ThreadSnoozeShell,
} from "@t3tools/client-runtime/state/thread-settled";
import {
Expand All @@ -38,6 +40,7 @@ import {
} from "@t3tools/client-runtime/state/thread-sort";
import type { SidebarThreadSummary, Thread } from "../types";
import { normalizeSearchText } from "../lib/utils";
import { parseTimestampDate } from "../timestampFormat";
import { isLatestRunSettled } from "../session-logic";
import { observeIntersection } from "../lib/observeIntersection";
import { deadlineAfter, deadlineStore } from "@t3tools/client-runtime/clock";
Expand Down Expand Up @@ -730,6 +733,36 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
return completedAt > lastVisitedAt;
}

/** A thread woke from its snooze after the user last opened it. */
export function isSidebarThreadWoke(input: {
readonly wokeAt: string | null;
readonly lastVisitedAt: string | undefined;
readonly settled: boolean;
}): boolean {
const wokeAtDate = input.wokeAt === null ? null : parseTimestampDate(input.wokeAt);
const lastVisitedDate =
input.lastVisitedAt === undefined ? null : parseTimestampDate(input.lastVisitedAt);
return (
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
!input.settled
);
}

/** The header may show Idle only where the thread's sidebar row would: active, read, not woke. */
export function threadHeaderMayShowIdle(
thread: ThreadStatusInput & ThreadSnoozeShell & Pick<SidebarThreadSummary, "settledOverride">,
options: { readonly lastVisitedAt: string | undefined; readonly now: string },
): boolean {
if (!isThreadListedActive(thread, { now: options.now })) return false;
if (hasUnseenCompletion({ ...thread, lastVisitedAt: options.lastVisitedAt })) return false;
return !isSidebarThreadWoke({
wokeAt: threadWokeAt(thread, { now: options.now }),
lastVisitedAt: options.lastVisitedAt,
settled: false,
});
}

/** Unopened threads' failures still count as unseen, unlike completions. */
function hasUnseenFailure(thread: ThreadStatusInput): boolean {
if (thread.latestRun?.status !== "failed") return false;
Expand Down
36 changes: 34 additions & 2 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type ScopedThreadRef,
type ThreadId,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
isAtomCommandInterrupted,
Expand All @@ -19,7 +19,12 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { isTrailingDoubleClick } from "../Sidebar.logic";
import {
isTrailingDoubleClick,
resolveThreadLastVisitedAt,
threadHeaderMayShowIdle,
} from "../Sidebar.logic";
import { useUiStateStore } from "../../uiStateStore";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { toastManager } from "../ui/toast";
Expand All @@ -42,6 +47,32 @@ import { cn } from "~/lib/utils";
import { textDirection } from "~/textDirection";
import { useClientSettings } from "../../hooks/useSettings";
import { GuestShareIndicator } from "./GuestShareControls";
import { useQuietThreadShell } from "../../state/entities";
import { useNowAfter } from "~/hooks/useDeadline";
import { useThreadHasNoPendingWake } from "../../hooks/useThreadHasNoPendingWake";

// Its own component, so shell updates during a turn re-render only the badge.
function ThreadIdleBadge(props: { threadRef: ScopedThreadRef }) {
const shell = useQuietThreadShell(props.threadRef);
const localLastVisitedAt = useUiStateStore(
(state) => state.threadLastVisitedAtById[scopedThreadKey(props.threadRef)],
);
// A clock read in render would stay frozen in the compiler's cache; this one moves when a snooze ends.
const now = new Date(useNowAfter(Date.parse(shell?.snoozedUntil ?? "") || null)).toISOString();
const mayShowIdle =
shell !== null &&
threadHeaderMayShowIdle(shell, {
lastVisitedAt: resolveThreadLastVisitedAt(shell.lastVisitedAt, localLastVisitedAt),
now,
});
if (!useThreadHasNoPendingWake(shell, mayShowIdle)) return null;
return (
<Tooltip>
<TooltipTrigger render={<Badge variant="outline" size="sm" />}>Idle</TooltipTrigger>
<TooltipPopup side="top">Nothing will resume this thread</TooltipPopup>
</Tooltip>
);
}

interface ChatHeaderProps {
activeThreadEnvironmentId: EnvironmentId;
Expand Down Expand Up @@ -418,6 +449,7 @@ export const ChatHeader = memo(function ChatHeader({
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
)}
{renamingTitle === null ? <ThreadIdleBadge threadRef={activeThreadRef} /> : null}
{renamingTitle === null && activeThreadLinkOrigin ? (
<Tooltip>
<TooltipTrigger render={<Badge variant="info" size="sm" />}>
Expand Down
Loading
Loading