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
74 changes: 74 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
branchMismatchKey,
buildBackgroundWorkBannerCopy,
buildExpiredTerminalContextToastCopy,
buildLoadingThreadFromShell,
buildThreadTurnInterruptInput,
Expand Down Expand Up @@ -308,6 +309,79 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});

describe("buildBackgroundWorkBannerCopy", () => {
const watch = (label: string) => ({ kind: "watch" as const, label });
const agent = (label: string) => ({ kind: "agent" as const, label });

it("names the watch loops it can see", () => {
expect(
buildBackgroundWorkBannerCopy({
liveness: "monitoring",
liveAgentCount: 0,
tasks: [watch("Watch CI on PR #18")],
}),
).toEqual({
title: "1 watch loop running in the background",
description: "Watch CI on PR #18",
});
});

it("counts the work that does not fit on the line", () => {
expect(
buildBackgroundWorkBannerCopy({
liveness: "monitoring",
liveAgentCount: 0,
tasks: [watch("Watch CI"), watch("Tail logs"), watch("Poll deploy")],
}),
).toEqual({
title: "3 watch loops running in the background",
description: "Watch CI, Tail logs, and 1 more",
});
});

it("keeps the old copy when retention left no task detail", () => {
expect(
buildBackgroundWorkBannerCopy({ liveness: "monitoring", liveAgentCount: 0, tasks: [] }),
).toEqual({ title: "Monitoring in the background", description: null });
expect(
buildBackgroundWorkBannerCopy({ liveness: "working", liveAgentCount: 0, tasks: [] }),
).toEqual({ title: "Background work running", description: null });
});

it("prefers the agent roster's count while agents are working", () => {
expect(
buildBackgroundWorkBannerCopy({
liveness: "working",
liveAgentCount: 2,
tasks: [agent("Review the diff"), agent("Write tests")],
}),
).toEqual({
title: "2 agents working in the background",
description: "Review the diff and Write tests",
});
});

it("falls back to the folded task count when the roster is empty", () => {
expect(
buildBackgroundWorkBannerCopy({
liveness: "working",
liveAgentCount: 0,
tasks: [agent("Orphaned run")],
}),
).toEqual({ title: "1 task running in the background", description: "Orphaned run" });
});

it("describes only the watch loops when monitoring", () => {
expect(
buildBackgroundWorkBannerCopy({
liveness: "monitoring",
liveAgentCount: 0,
tasks: [agent("Stale agent row"), watch("Watch CI")],
}).description,
).toBe("Watch CI");
});
});

describe("getStartedThreadModelChangeBlockReason", () => {
const providers = [
{
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,72 @@ export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "sessi
};
}

export const BACKGROUND_WORK_LABEL_LIMIT = 2;

function pluralize(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`;
}

/**
* One line naming the live background work, listing what fits and counting the
* rest. The banner has a single line, and the popover carries the full list.
*/
export function formatBackgroundWorkLabels(
labels: ReadonlyArray<string>,
limit = BACKGROUND_WORK_LABEL_LIMIT,
): string | null {
const shown = labels.slice(0, Math.max(limit, 1));
const hidden = labels.length - shown.length;
if (hidden > 0) {
return `${shown.join(", ")}, and ${hidden} more`;
}
if (shown.length > 1) {
return `${shown.slice(0, -1).join(", ")} and ${shown.at(-1)}`;
}
return shown[0] ?? null;
}

/**
* Banner copy for background work that outlived the turn. The server's
* liveness state is authoritative for working-vs-monitoring and for whether
* anything is live at all; the folded task list only adds detail and is empty
* whenever start rows aged out or the server restarted, so every branch reads
* without it.
*/
export function buildBackgroundWorkBannerCopy(input: {
liveness: "working" | "monitoring";
liveAgentCount: number;
tasks: ReadonlyArray<{ readonly kind: "watch" | "agent"; readonly label: string }>;
}): { title: string; description: string | null } {
const watchTasks = input.tasks.filter((task) => task.kind === "watch");
const described =
input.liveness === "monitoring" && watchTasks.length > 0 ? watchTasks : input.tasks;
const description = formatBackgroundWorkLabels(described.map((task) => task.label));

if (input.liveness === "monitoring") {
return {
title:
watchTasks.length > 0
? `${pluralize(watchTasks.length, "watch loop", "watch loops")} running in the background`
: "Monitoring in the background",
description,
};
}
if (input.liveAgentCount > 0) {
return {
title: `${pluralize(input.liveAgentCount, "agent", "agents")} working in the background`,
description,
};
}
return {
title:
input.tasks.length > 0
? `${pluralize(input.tasks.length, "task", "tasks")} running in the background`
: "Background work running",
description,
};
}

export function reconcileMountedTerminalThreadIds(input: {
currentThreadIds: ReadonlyArray<string>;
openThreadIds: ReadonlyArray<string>;
Expand Down
63 changes: 49 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ import {
deriveAgentPanelModel,
foldSubagentActivities,
} from "@t3tools/client-runtime/state/subagentRuntime";
import {
foldLiveBackgroundTasks,
type LiveBackgroundTask,
} from "@t3tools/client-runtime/state/background-work";
import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
import { BranchToolbar } from "./BranchToolbar";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
Expand Down Expand Up @@ -293,6 +297,7 @@ import {
import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
branchMismatchKey,
buildBackgroundWorkBannerCopy,
buildExpiredTerminalContextToastCopy,
buildLocalDraftThread,
buildLoadingThreadFromShell,
Expand Down Expand Up @@ -341,6 +346,7 @@ import {
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { BackgroundWorkDetailsPopover } from "./chat/BackgroundWorkDetailsPopover";
import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
import {
buildVersionMismatchDismissalKey,
Expand All @@ -358,6 +364,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
const EMPTY_LIVE_BACKGROUND_TASKS: ReadonlyArray<LiveBackgroundTask> = [];
function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
const composerAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -4391,6 +4398,16 @@ function ChatViewContent(props: ChatViewProps) {
// interrupting, and works by session, so no active turn is needed.
const activeBackgroundLiveness =
!isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null;
// The liveness state names a mode, not the work, so the banner also folds
// the thread's task rows for per-task detail. Gated on the liveness so a
// thread with nothing running never pays for the fold.
const liveBackgroundTasks = useMemo(
() =>
activeBackgroundLiveness === null
? EMPTY_LIVE_BACKGROUND_TASKS
: foldLiveBackgroundTasks(threadActivities),
[activeBackgroundLiveness, threadActivities],
);
const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false);
useEffect(() => {
// "Stopping..." holds until the liveness clears; the interrupt command
Expand Down Expand Up @@ -4430,7 +4447,11 @@ function ChatViewContent(props: ChatViewProps) {
return null;
}
const working = activeBackgroundLiveness === "working";
const liveCount = agentPanelModel.liveCount;
const copy = buildBackgroundWorkBannerCopy({
liveness: working ? "working" : "monitoring",
liveAgentCount: agentPanelModel.liveCount,
tasks: liveBackgroundTasks,
});
return {
id: `background-liveness:${activeThread.id}`,
variant: "default",
Expand All @@ -4440,20 +4461,33 @@ function ChatViewContent(props: ChatViewProps) {
aria-hidden="true"
/>
),
title: working
? liveCount > 0
? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
: "Background work running"
: "Monitoring in the background",
title: copy.title,
// One line, always: a provider-written task title can run long and the
// banner must not grow to fit it. The popover has the full text.
...(copy.description
? { description: <span className="block truncate">{copy.description}</span> }
: {}),
actions: (
<Button
size="xs"
variant="outline"
disabled={isStoppingBackgroundWork}
onClick={() => void handleStopBackgroundWork()}
>
{isStoppingBackgroundWork ? "Stopping..." : "Stop"}
</Button>
<>
{liveBackgroundTasks.length > 0 ? (
<BackgroundWorkDetailsPopover tasks={liveBackgroundTasks} />
) : null}
<Tooltip>
<TooltipTrigger
render={
<Button
size="xs"
variant="outline"
disabled={isStoppingBackgroundWork}
onClick={() => void handleStopBackgroundWork()}
/>
}
>
{isStoppingBackgroundWork ? "Stopping..." : "Stop"}
</TooltipTrigger>
<TooltipPopup side="top">Ends all background work in this thread at once</TooltipPopup>
</Tooltip>
</>
),
};
}, [
Expand All @@ -4462,6 +4496,7 @@ function ChatViewContent(props: ChatViewProps) {
agentPanelModel.liveCount,
handleStopBackgroundWork,
isStoppingBackgroundWork,
liveBackgroundTasks,
]);
// A woken thread announces itself in the open view, not just the sidebar
// pill. Dismissing marks the wake as seen (same acknowledgment as the
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/chat/BackgroundWorkDetailsPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { LiveBackgroundTask } from "@t3tools/client-runtime/state/background-work";

import { formatRelativeTimeLabel } from "~/timestampFormat";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";

/**
* Names the live background work behind the composer banner. Timestamps are
* formatted at open time rather than ticking: the popup is built fresh on every
* open, so a repainting clock would cost frames for nothing.
*/
export function BackgroundWorkDetailsPopover({
tasks,
}: {
readonly tasks: ReadonlyArray<LiveBackgroundTask>;
}) {
return (
<Popover>
<PopoverTrigger render={<Button size="xs" variant="ghost" />}>Details</PopoverTrigger>
<PopoverPopup align="end" side="top" className="w-80 max-w-full">
<div className="flex flex-col gap-3">
<ul className="flex flex-col gap-2.5">
{tasks.map((task) => (
<li key={task.taskId} className="flex flex-col gap-0.5">
<div className="flex items-baseline gap-2">
<span className="text-[11px] text-muted-foreground uppercase tracking-wide">
{taskFlavorLabel(task)}
</span>
<span className="text-[11px] text-muted-foreground">
{formatRelativeTimeLabel(task.updatedAt)}
</span>
</div>
<span className="font-medium text-sm">{task.label}</span>
{task.progress ? (
<span className="text-muted-foreground text-xs">{task.progress}</span>
) : null}
</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
Stop ends everything listed here at once and interrupts the session. There is no way to
stop a single item.
</p>
</div>
</PopoverPopup>
</Popover>
);
}

function taskFlavorLabel(task: LiveBackgroundTask): string {
if (task.kind === "agent") {
return "Agent";
}
return task.taskType === "shell" || task.taskType === "local_bash"
? "Background shell"
: "Monitor";
}
42 changes: 42 additions & 0 deletions docs/fork/0011-follow-background-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 0011: Follow the background work a thread left running

- PR: [TrogonStack/t3code#20](https://github.com/TrogonStack/t3code/pull/20)
- Status: active

## What you can do now

- Read what is actually running when a thread keeps working after its turn
ends. The composer banner names the work instead of only naming a mode, so
"Monitoring in the background" becomes the watch loops by name.
- Open Details on that banner for the full list: what each item is, its latest
progress line, and when it last reported.
- Know what Stop does before pressing it. The banner and the list both say it
ends every listed item at once, which is the only granularity there is.

## Why

A thread can hold background work open for hours, and the banner was the only
place that admitted it. It reported a state and offered a single destructive
button, which left two questions unanswered at the moment they matter: what is
still running, and what am I about to kill. People who could not answer either
one stopped everything to find out, which is the opposite of what the feature
is for.

Monitoring was the worse of the two states. It is by definition the state with
no live agents, so the roster people would otherwise check is empty exactly when
the banner is up, and a watch loop that is waiting on something looks identical
to one that has quietly wedged. Its progress line is the difference, and it was
already being recorded, just never shown.

## Upstream considerations

This is a presentation change over data the server already persists, with no
contract, wire, or server change, so it should go upstream as a feature. Submit
it and delete this entry once it merges.

While it is carried, the chat view is the part a sync will notice, since it
moves often upstream. The rest is additive: one shared derivation and one
self-contained popover.

Mobile is deliberately untouched: it has no background-liveness banner to
improve. Desktop wraps the web client and picks this up with it.
1 change: 1 addition & 0 deletions docs/fork/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ Each entry uses these sections:
| 0006 | [Fork schema on its own migration ledger](./0006-fork-migration-ledger.md) | [#13](https://github.com/TrogonStack/t3code/pull/13), [#16](https://github.com/TrogonStack/t3code/pull/16) | active |
| 0007 | [API-key Codex installs are not reported as broken](./0007-codex-api-key-auth-is-supported.md) | [#15](https://github.com/TrogonStack/t3code/pull/15) | active |
| 0008 | [Drop a folder on the sidebar to add a project](./0008-drop-a-folder-to-add-a-project.md) | [#17](https://github.com/TrogonStack/t3code/pull/17) | active |
| 0011 | [Follow the background work a thread left running](./0011-follow-background-work.md) | [#20](https://github.com/TrogonStack/t3code/pull/20) | active |
Loading
Loading