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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user