chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
@@ -26,7 +26,14 @@
* terminal (`waiting`) — and, like the terminal's status line, how long the
* turn has run and the output tokens it has made; a call running long shows
* its own time, and a line silent for minutes turns amber. A turn that ended
* closes with how long it took and its output.
* closes with how long it took and its output. A live chat also lists the
* subagents it launched in the background that have not reported back yet
* (an `Agent` call whose result is "Async agent launched…" and no
* `<task-notification>` naming its call since), like the terminal's footer:
* their turn returns at once, so without the list a turn that ended reads
* "Done" while they still work (issue #81) — it then reads "Replied in …".
* Given the `sessionId`, each line also shows what that agent is doing now
* and how large its context is, read off the tail of its own transcript.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -101,6 +108,7 @@ import {
CornerDownRight,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line
* is not always the human (tool results, harness task-notifications, the
@@ -163,6 +171,9 @@ interface MessageListProps {
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
/** The session whose MAIN transcript this is: its background agents'
* own transcripts are read for their progress. */
sessionId?: string;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -641,6 +652,7 @@ function MessageListView({
waiting = false,
onOpenTerminal,
stopped = 0,
sessionId,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -659,6 +671,10 @@ function MessageListView({
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
[live, messages, toolResultMap]
);
if (loading) {
return (
@@ -715,7 +731,14 @@ function MessageListView({
/>
);
case "done":
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
return (
<TurnDone
key={item.key}
ms={item.ms}
output={item.output}
agents={item === items[items.length - 1] ? agents.length : 0}
/>
);
}
})}
{pending && (
@@ -727,6 +750,7 @@ function MessageListView({
onOpenTerminal={onOpenTerminal}
/>
)}
{agents.length > 0 && <BackgroundAgents agents={agents} sessionId={sessionId} />}
</div>
</OpenFileContext.Provider>
);
@@ -783,6 +807,48 @@ export function workingOn(
return alive ? { blocks, since: null, last } : null;
}
// A launch older than this with no notification is from a run that ended
// without one (the CLI quit, a resume of an old session): not running now.
const AGENT_STALE_MS = 6 * 60 * 60_000;
const NOTIFIED_CALL = /<tool-use-id>([^<]+)<\/tool-use-id>/g;
// The launch result names the agent's own transcript (`agent-<id>.jsonl`).
const LAUNCHED_ID = /agentId:\s*([\w-]+)/;
type BackgroundAgent = { block: TranscriptContent; since: string | null; agentId: string | null };
/** The subagents launched in the background that have not reported back: an
* Agent call whose result says it was launched async, with no
* `<task-notification>` naming that call since (any status — completed,
* failed, killed — means it stopped). `since` is its launch time. */
export function backgroundAgents(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): BackgroundAgent[] {
const running = new Map<string, BackgroundAgent>();
for (const msg of messages) {
if (msg.type === "assistant") {
for (const b of msg.content) {
if (b.type !== "tool_use" || !b.id || (b.name !== "Agent" && b.name !== "Task")) continue;
// The output is the result content as JSON when it is a block list.
const output = results.get(b.id)?.output;
if (!output?.includes("Async agent launched")) continue;
const at = msg.timestamp ?? null;
if (at && now - parseDate(at).getTime() > AGENT_STALE_MS) continue;
running.set(b.id, { block: b, since: at, agentId: output.match(LAUNCHED_ID)?.[1] ?? null });
}
continue;
}
if (!running.size) continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text || !isTaskNotification(c.text)) continue;
for (const m of c.text.matchAll(NOTIFIED_CALL)) running.delete(m[1]!.trim());
}
}
return [...running.values()];
}
/** Output tokens counted once per API call: Claude Code writes one line per
* content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
@@ -857,6 +923,23 @@ function useCountUp(value: number): number {
return shown;
}
/** The time now, ticking every second while `ref`'s box is on screen: a
* hidden tab or a hidden pane (another workspace, kept mounted) shows no
* clock, so the re-render is skipped until it is shown again. */
function useClock(ref: React.RefObject<HTMLElement | null>): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
const el = ref.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [ref]);
return now;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
@@ -886,20 +969,9 @@ function RunningStep({
onOpenTerminal?: () => void;
}) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
// no clock: skip the re-render until it is on screen again.
const timer = window.setInterval(() => {
const el = boxRef.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
const now = useClock(boxRef);
const counted = useCountUp(tokens);
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
const first = blocks[0];
@@ -976,7 +1048,159 @@ function RunningStep({
);
}
/** Under a turn that ended: how long it took and the output it made. */
// How often a running agent's transcript tail is re-read, and how much of it
// is kept: enough for its newest step and its newest usage.
const AGENT_TAIL_MS = 4000;
const AGENT_TAIL_KEEP = 40;
/** The newest lines of each agent's own transcript (by its `agentId`),
* re-read while `ref`'s box is on screen — only the lines after those
* already read (`after`). A failed read leaves the line without detail. */
function useAgentTails(
sessionId: string | undefined,
ids: string[],
ref: React.RefObject<HTMLElement | null>
): Map<string, TranscriptMessage[]> {
const [tails, setTails] = useState(() => new Map<string, TranscriptMessage[]>());
const lastRef = useRef(new Map<string, number>());
const key = ids.join(",");
useEffect(() => {
if (!sessionId || !key) return;
let cancelled = false;
let busy = false;
const read = async () => {
const el = ref.current;
if (
busy ||
document.visibilityState !== "visible" ||
(el && el.getClientRects().length === 0)
)
return;
busy = true;
await Promise.all(
key.split(",").map(async (id) => {
const last = lastRef.current.get(id);
try {
const r = await api.sessions.transcript(sessionId, {
agent_id: id,
...(last ? { after: last } : { limit: 20 }),
});
if (cancelled || !r.messages.length) return;
lastRef.current.set(id, r.last_line);
setTails((prev) => {
const next = new Map(prev);
next.set(id, [...(prev.get(id) ?? []), ...r.messages].slice(-AGENT_TAIL_KEEP));
return next;
});
} catch {
// Non-fatal: the line keeps its description and time.
}
})
);
busy = false;
};
void read();
const timer = window.setInterval(() => void read(), AGENT_TAIL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [sessionId, key, ref]);
return tails;
}
/** What an agent's transcript tail says it is doing: the call it waits on
* (or none while it thinks), and its newest prompt size — its context. */
export function agentProgress(
messages: TranscriptMessage[],
now = Date.now()
): { step: string | null; context: number } {
let context = 0;
for (let i = messages.length - 1; i >= 0 && !context; i--) {
const u = messages[i]!.usage;
if (messages[i]!.type === "assistant" && u) context = promptTokens(u);
}
const first = workingOn(messages, buildToolResultMap(messages), now)?.blocks[0];
return { step: first ? stepTitle(first) : null, context };
}
/** The subagents still working in the background, one line each like the
* terminal's footer: its type, its task and how long it has run — with its
* step now and its context once its own transcript was read. */
function BackgroundAgents({
agents,
sessionId,
}: {
agents: BackgroundAgent[];
sessionId?: string;
}) {
const { t } = useTranslation("sessions");
const boxRef = useRef<HTMLDivElement>(null);
const now = useClock(boxRef);
const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]);
const tails = useAgentTails(sessionId, ids, boxRef);
return (
<div
ref={boxRef}
data-testid="chat-background-agents"
className="overflow-hidden rounded-xl border border-border text-sm"
>
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-fg-primary">
<Bot className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.agentsRunning", { count: agents.length })}</span>
</div>
<ul className="divide-y divide-border">
{agents.map(({ block, since, agentId }, i) => {
const input = (block.input ?? {}) as Record<string, unknown>;
const type = typeof input.subagent_type === "string" ? input.subagent_type : "Agent";
const desc = typeof input.description === "string" ? input.description : "";
const tail = agentId ? tails.get(agentId) : undefined;
const { step, context } = tail ? agentProgress(tail, now) : { step: null, context: 0 };
return (
<li
key={block.id ?? i}
data-testid="chat-background-agent"
className="px-3 py-2 text-fg-secondary"
>
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="flex-shrink-0 rounded bg-surface-3/60 px-1.5 py-0.5 font-mono text-xs text-fg-muted">
{type}
</span>
<span className="min-w-0 flex-1 truncate" title={desc}>
{desc}
</span>
{[
since && formatMs(now - parseDate(since).getTime()),
context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`,
]
.filter(Boolean)
.map((v, j) => (
<span key={j} className="flex-shrink-0 font-mono text-xs text-fg-muted">
{v}
</span>
))}
</div>
{tail && (
<div
data-testid="chat-background-agent-step"
className="ml-[1.375rem] mt-0.5 flex items-center gap-1 text-xs text-fg-muted"
>
<CornerDownRight className="h-3 w-3 flex-shrink-0" />
<span className="min-w-0 truncate">{step ?? t("conversation.working")}</span>
</div>
)}
</li>
);
})}
</ul>
</div>
);
}
/** Under a turn that ended: how long it took and the output it made — or,
* while subagents it launched still work (`agents`), that only the reply is
* done. */
/** An option's label carries the model's " (Recommended)" marker into the
* answer the CLI records; the user's pick reads without it (every picked
* label of a multi-select answer too). */
@@ -1028,14 +1252,21 @@ function AskCard({
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
function TurnDone({ ms, output, agents = 0 }: { ms: number; output: number; agents?: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{agents > 0 ? (
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
) : (
<Check className="h-3 w-3 flex-shrink-0" />
)}
{[
t("conversation.turnDone", { time: formatMs(ms) }),
t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", {
time: formatMs(ms),
}),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
agents > 0 && t("conversation.agentsStill", { count: agents }),
]
.filter(Boolean)
.join(" · ")}
@@ -1045,8 +1276,8 @@ function TurnDone({ ms, output }: { ms: number; output: number }) {
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// What the prompt box sends for an attached file: its saved path (an `@`
// mention for claude) under the drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_PATH =
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
@@ -1066,7 +1297,7 @@ export function splitDropImages(text: string): {
const files: string[] = [];
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
if (IMAGE_EXT.test(name)) images.push(name);
else files.push(full);
else files.push(full.replace(/^@/, "")); // a claude prompt's `@` mention
return "";
});
return {