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
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}
if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
return {
kind: "waiting",
kind: "background",
label: pendingBackgroundWork.title,
accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items
.map((item) => item.label)
.join(", ")}`,
waiting: pendingBackgroundWork.waiting,
};
}
return null;
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { withUniwind } from "uniwind";

import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
import type { FloatingWorkingStatus } from "./floating-working-status";
import { backgroundStatusSymbol, type FloatingWorkingStatus } from "./floating-working-status";
import { ControlPill } from "../../components/ControlPill";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { DevicePreviewButton } from "../devices/device-preview-button";
Expand Down Expand Up @@ -424,16 +424,16 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "waiting") {
if (props.status.kind === "background") {
return (
<StatusLabelRow
key="waiting"
key="background"
accessibilityLabel={props.status.accessibilityLabel}
className="gap-2"
onLayout={props.onLayout}
>
<SymbolView
name={{ ios: "bolt", android: "bolt" }}
name={backgroundStatusSymbol(props.status.waiting)}
size={13}
tintColorClassName="foreground"
type="monochrome"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { connectionFloatingStatus } from "./floating-working-status";
import { backgroundStatusSymbol, connectionFloatingStatus } from "./floating-working-status";

const status = (
connectionState: Parameters<typeof connectionFloatingStatus>[0]["connectionState"],
Expand Down Expand Up @@ -64,3 +64,10 @@ describe("connectionFloatingStatus", () => {
expect(onReconnect).toHaveBeenCalledOnce();
});
});

describe("backgroundStatusSymbol", () => {
it("shows the bolt only for work that will wake the agent", () => {
expect(backgroundStatusSymbol(true)).toEqual({ ios: "bolt", android: "bolt" });
expect(backgroundStatusSymbol(false)).toBe("terminal");
});
});
18 changes: 16 additions & 2 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ export type FloatingWorkingStatus =
| { readonly kind: "working"; readonly startedAt: string; readonly step?: string }
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
// The turn settled while background work it started still runs.
| { readonly kind: "waiting"; readonly label: string; readonly accessibilityLabel: string }
// The turn settled while background work it started still runs. `waiting`
// is false when only commands remain, such as a dev server: the agent is done.
| {
readonly kind: "background";
readonly label: string;
readonly accessibilityLabel: string;
readonly waiting: boolean;
}
// A task whose thread the server has not created yet: the worktree may
// still be checking out, so there is no turn to time.
| { readonly kind: "preparing"; readonly label: string }
Expand All @@ -22,6 +28,14 @@ export type FloatingWorkingStatus =
readonly onPress: () => void;
};

/**
* The symbol for settled background work. A dev server can run for hours after
* the agent is done, so only work that will wake the agent gets the bolt.
*/
export function backgroundStatusSymbol(waiting: boolean) {
return waiting ? ({ ios: "bolt", android: "bolt" } as const) : ("terminal" as const);
}

/**
* The pill's connection variant, or null once the environment is connected and
* the pill is free to report sync and working state instead.
Expand Down
31 changes: 27 additions & 4 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import * as DateTime from "effect/DateTime";
import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
import {
createPendingThreadOrder,
Expand All @@ -23,7 +25,7 @@ import {
import { describe, expect, it, vi } from "vite-plus/test";

import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { makeThreadShellFixture } from "../../test-fixtures";
import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures";
import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
import {
buildThreadListV2Items,
Expand All @@ -37,6 +39,7 @@ import {
resolveThreadListV2SwipeActions,
sortThreadsForListV2,
threadListV2ListItemsAreEqual,
threadHasUnseenCompletion,
type ThreadListV2ListItem,
} from "./threadListV2";

Expand Down Expand Up @@ -184,9 +187,7 @@ describe("resolveThreadListV2Status", () => {
makeThread({
id: ThreadId.make("t"),
title: "t",
pendingBackgroundTasks: [
{ taskId: "bg-1", description: "Run Codex review", kind: "command" },
],
pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
runtime: {
status: "idle",
activeRunId: null,
Expand All @@ -200,6 +201,28 @@ describe("resolveThreadListV2Status", () => {
).toBe("waiting");
});

it.each([
{ kind: "command", status: "ready" },
{ kind: "monitor", status: "waiting" },
] as const)(
"presents an unseen completion with a $kind roster as $status",
({ kind, status }) => {
const thread = presentThreadShell(
environmentId,
makeRawThreadShell({
latestRunId: RunId.make("run-background-completion"),
status: "completed",
latestRunCompletedAt: DateTime.makeUnsafe(NOW),
lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"),
pendingBackgroundTasks: [{ taskId: "background-work", kind }],
}),
);

expect(resolveThreadListV2Status(thread)).toBe(status);
expect(threadHasUnseenCompletion(thread)).toBe(true);
},
);

it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export function resolveThreadListV2ProviderDrivers(
* failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
* parked on open background tasks, grey like working rather than a false Done.
* The orchestrator v2 presentation bridge parks runtime at idle when the
* post-settlement background roster is nonempty.
* post-settlement background roster holds the run's completion (subagents,
* monitors); commands left running, such as a dev server, read as ready.
*/
export type ThreadListV2Status =
| "approval"
Expand Down
65 changes: 60 additions & 5 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => {
]);
});

it("keeps imported V1 turns folded once the thread's first V2 run starts", () => {
const imported = <T extends OrchestrationV2TurnItem>(item: T, id: string) => ({
...item,
id: TurnItemId.make(id),
runId: null,
});
const presented = (start: OrchestrationV2TurnItem) =>
deriveThreadFeedPresentation(
buildThreadFeed(
[
imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"),
imported(
{
...assistantMessage("2026-06-20T00:00:02.000Z"),
messageId: MessageId.make("update"),
},
"imported-update",
),
imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"),
imported(
{
...assistantMessage("2026-06-20T00:00:08.000Z"),
messageId: MessageId.make("answer"),
},
"imported-answer",
),
start,
].map((item, position) => projected(item, position)),
),
{ runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null },
new Set(),
new Set(),
"2026-06-20T00:01:00.000Z",
)
.slice(0, 4)
.map((entry) => (entry.type === "message" ? entry.message.role : entry.type));

// A sent prompt and an automatic wake both start V2 work below the import.
expect(
presented({
...userMessage("2026-06-20T00:01:00.000Z"),
id: TurnItemId.make("new-prompt"),
messageId: MessageId.make("new-prompt"),
}),
).toEqual(["user", "assistant", "run-fold", "assistant"]);
expect(
presented({
...base("wake", "2026-06-20T00:01:00.000Z", 4),
type: "notification",
source: { kind: "background_task" },
outcome: "completed",
summary: "Background task finished",
}),
).toEqual(["user", "assistant", "run-fold", "assistant"]);
});

it("keeps a provider-native subagent's runless tool call live while it works", () => {
const startedAt = "2026-06-20T00:00:01.000Z";
const { exitCode: _exitCode, ...completedCommand } = command();
Expand Down Expand Up @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => {
expect(presented.some((entry) => entry.type === "thinking")).toBe(false);
});

it("keeps a runless tail settled while a normal thread waits for its sent run", () => {
it("keeps a runless tail folded while a normal thread waits for its sent run", () => {
// Right after a send the local clock runs before the server creates the
// run, and the latest run may still be queued: neither is runless work.
// run, and the latest run may still be queued: neither is runless work,
// so the settled tail must not reopen and shift the feed.
const startedAt = "2026-06-20T00:00:05.000Z";
const feed = buildThreadFeed([
projected({ ...userMessage(), runId: null }, 0),
Expand All @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => {
new Set(),
startedAt,
);
const toggle = presented.find((entry) => entry.type === "work-toggle");
expect(toggle).toMatchObject({ live: false, shimmer: false });
expect(presented.at(-1)?.type).toBe("thinking");
expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]);
}
});

Expand Down
22 changes: 14 additions & 8 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,13 +972,14 @@ export function failedFeedRunIds(
}

/**
* A thread without runs (a provider-native subagent) folds each prompt's
* response like a run; `isWorking` keeps its latest response open.
* A prompt without a run (a provider-native subagent, or a turn imported from
* V1) folds its response like a run. `runlessWorkActive` keeps the latest
* runless response open; V2 work must not reopen imported turns.
*/
function deriveThreadFeedRunFolds(
feed: ReadonlyArray<ThreadFeedEntry>,
latestRun: ThreadFeedLatestRun | null,
isWorking: boolean,
runlessWorkActive: boolean,
): ReadonlyMap<string, ThreadFeedRunFold> {
const firstAssistantMessageIdByRun = new Map<RunId, string>();
const terminalAssistantMessageIdByRun = new Map<RunId, string>();
Expand All @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds(
RunId,
{ entries: ThreadFeedEntry[]; startBoundary: string | null }
>();
// Fold state is keyed by run, so each prompt of a runless thread lends its
// response a stable key of its own.
// Fold state is keyed by run, so each runless prompt lends its response a
// stable key of its own. Decide per prompt, not per thread: a V1 thread's
// first V2 run must not unfold every imported turn above it.
let runlessKey: RunId | null = null;
let pendingUserBoundary: string | null = null;
for (const entry of feed) {
if (entry.type === "message" && entry.message.role === "user") {
pendingUserBoundary = entry.message.createdAt;
runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null;
runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;
continue;
}
const runId =
Expand Down Expand Up @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds(
for (const [runId, group] of groupsByRunId) {
if (
runId === activeRunId ||
(isWorking && runId === runlessKey) ||
(runlessWorkActive && runId === runlessKey) ||
interruptedRunIds.has(runId) ||
failedRunIds.has(runId) ||
group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
Expand Down Expand Up @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation(
const activeTailGroup = sourceFeed.at(-1);
const activeRunId = unsettledRunId(latestRun);
const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing";
const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking);
const foldsByAnchorId = deriveThreadFeedRunFolds(
sourceFeed,
latestRun,
isWorking && runlessWorkActive,
);
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorId.values()) {
if (!expandedRunIds.has(fold.runId)) {
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});

it("holds a first send while the thread shell still reports a preparing run", () => {
// The draft had no run. The server thread's shell shows the new run before
// the detail projection behind `phase` loads.
const localDispatch = createLocalDispatchSnapshot(makeThread());
const preparingRun = {
...completedTurn,
status: "preparing" as const,
startedAt: null,
completedAt: null,
};

expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "disconnected",
latestRun: preparingRun,
runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId },
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
}),
).toBe(false);
});

it("waits for the matching running turn before acknowledging", () => {
const localDispatch = createLocalDispatchSnapshot(
makeThread({ latestRun: completedTurn, runtime: readySession }),
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { filterTerminalContextsWithText, type TerminalContextDraft } from "../li
import { stripInlineContextReferences } from "~/lib/composerContextReferences";
import type { DraftThreadEnvMode } from "../composerDraftStore";
import type { ComposerSubmissionIntent } from "../composer-logic";
import type { TimelineEntry } from "../session-logic";
import { derivePhase, type TimelineEntry } from "../session-logic";
import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
import type { DesktopPreviewOverlay } from "../previewStateStore";
import type { RightPanelSurface } from "../rightPanelStore";
Expand Down Expand Up @@ -1242,7 +1242,10 @@ export function hasServerAcknowledgedLocalDispatch(input: {
if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
return true;
}
if (input.phase === "connecting") {
// The thread shell can report a preparing or starting run before the detail
// projection behind `phase` loads, so either source still connecting holds
// the send.
if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") {
return false;
}

Expand Down
Loading
Loading