fix(web): show file summary after task completion (#143)
Co-authored-by: Youhai <185432073+Youhai020616@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7 files changed
+108
-51
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
# File summaries wait for the Task to finish
|
||||||
|
|
||||||
|
The main conversation's file summary no longer appears after an intermediate assistant message while tools and later model turns are still running. It now appears once at the completed Task boundary and scans all assistant text from that Task, so paths mentioned before a tool call are still available in the final summary. Nested agent conversations keep their existing per-message summaries because their embedded stream does not expose the parent view's Task footer. File-existence caching also stops retaining negative results, allowing a later Task to create and surface a path that was previously absent. Existence checks go out in server-sized batches, so a long Task referencing more paths than one files/stat call accepts still gets its summary instead of silently losing it.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
# Unreleased
|
# Unreleased
|
||||||
|
|
||||||
Changes since v0.1.5. The version number is assigned at release, when this folder is renamed.
|
Changes since v0.1.5. The version number is assigned at release, when this folder is renamed.
|
||||||
|
|
||||||
|
- [2026-07-30] Web App: the main conversation's file summary moves to the completed Task boundary — one card per Task scanning all of its assistant text, nested agent conversations keep their per-message summaries, and file-existence caching stops retaining negative results so a later Task can surface a newly created path. ([details](2026-07-30-file-summary-task-boundary.md))
|
||||||
@@ -215,6 +215,13 @@ function headerStats(
|
|||||||
*/
|
*/
|
||||||
export const DRAFT_SESSION_ID = "new";
|
export const DRAFT_SESSION_ID = "new";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-enforced ceiling on paths per files/stat call (STAT_MAX_PATHS in the sessions routes,
|
||||||
|
* which 400s above it). A Task-level summary aggregates candidates across the whole Task and can
|
||||||
|
* exceed it, so cache misses are checked in chunks of this size.
|
||||||
|
*/
|
||||||
|
const STAT_PATHS_PER_REQUEST = 100;
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const params = useParams<{ sessionId?: string }>();
|
const params = useParams<{ sessionId?: string }>();
|
||||||
@@ -451,10 +458,10 @@ export function ChatPage() {
|
|||||||
}
|
}
|
||||||
}, [stream.taskState, reloadSessions, reloadAgents]);
|
}, [stream.taskState, reloadSessions, reloadAgents]);
|
||||||
|
|
||||||
// Existence cache for message file cards (session-level): normalized relative path -> whether
|
// Positive-only existence cache for file summary cards (session-level): normalized relative
|
||||||
// it exists; while a lookup is in flight, the cache shares a single Promise, so a batch of
|
// path -> true, or the shared in-flight lookup. Missing files aren't retained — a later Task may
|
||||||
// concurrent mounts only issues one files/stat call.
|
// create the same path, so its summary must re-check instead of inheriting stale false state.
|
||||||
const statCacheRef = useRef(new Map<string, boolean | Promise<boolean>>());
|
const statCacheRef = useRef(new Map<string, true | Promise<boolean>>());
|
||||||
|
|
||||||
// Session switch: resets the cost, the file-card existence cache, and the per-turn thinking
|
// Session switch: resets the cost, the file-card existence cache, and the per-turn thinking
|
||||||
// level (it's per-session UI state), avoiding stale data from the previous Session (Files
|
// level (it's per-session UI state), avoiding stale data from the previous Session (Files
|
||||||
@@ -466,34 +473,30 @@ export function ChatPage() {
|
|||||||
statCacheRef.current = new Map();
|
statCacheRef.current = new Map();
|
||||||
}, [routeSessionId]);
|
}, [routeSessionId]);
|
||||||
|
|
||||||
// Batched existence check (message file cards): merges only the cache-miss paths into a single
|
// Batched existence check for file summaries: cache stable positive results and share in-flight
|
||||||
// files/stat call, and the result lands in the session-level cache — during streaming, the
|
// requests, but never retain a negative result. Each pending lookup mutates the cache only while
|
||||||
// candidate set is re-checked on every change, and the cache ensures only new paths trigger a
|
// it is still the current entry, so an old request can't delete or overwrite a newer one.
|
||||||
// request. On request failure, the placeholder is cleared (don't permanently cache a "couldn't
|
|
||||||
// find" as "doesn't exist"), returned as not-existing this time, and re-checked on the next mount.
|
|
||||||
const statFiles = useCallback(
|
const statFiles = useCallback(
|
||||||
async (paths: string[]): Promise<ReadonlySet<string>> => {
|
async (paths: string[]): Promise<ReadonlySet<string>> => {
|
||||||
const sessionId = selected?.sessionId ?? null;
|
const sessionId = selected?.sessionId ?? null;
|
||||||
const cache = statCacheRef.current;
|
const cache = statCacheRef.current;
|
||||||
const misses = sessionId === null ? [] : paths.filter((p) => !cache.has(p));
|
const misses = sessionId === null ? [] : paths.filter((p) => !cache.has(p));
|
||||||
if (sessionId !== null && misses.length > 0) {
|
if (sessionId !== null && misses.length > 0) {
|
||||||
const batch = api
|
for (let i = 0; i < misses.length; i += STAT_PATHS_PER_REQUEST) {
|
||||||
.statSessionFiles(sessionId, misses)
|
const chunk = misses.slice(i, i + STAT_PATHS_PER_REQUEST);
|
||||||
.then((res) => new Set(res.existing))
|
const batch = api
|
||||||
.catch(() => null);
|
.statSessionFiles(sessionId, chunk)
|
||||||
for (const p of misses) {
|
.then((res) => new Set(res.existing))
|
||||||
cache.set(
|
.catch(() => null);
|
||||||
p,
|
for (const p of chunk) {
|
||||||
batch.then((existing) => {
|
const pending = batch.then((existing) => existing?.has(p) ?? false);
|
||||||
if (existing === null) {
|
cache.set(p, pending);
|
||||||
cache.delete(p);
|
void pending.then((exists) => {
|
||||||
return false;
|
if (cache.get(p) !== pending) return;
|
||||||
}
|
if (exists) cache.set(p, true);
|
||||||
const exists = existing.has(p);
|
else cache.delete(p);
|
||||||
cache.set(p, exists);
|
});
|
||||||
return exists;
|
}
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = new Set<string>();
|
const result = new Set<string>();
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Message-level file summary card (visual reference: Codex's "files changed" card): extracts
|
* File summary card for a supplied assistant-text scope (visual reference: Codex's "files
|
||||||
* file paths from inline code in the assistant's text (heuristic via isFilePathLike), normalizes
|
* changed" card): the root conversation passes a completed Task's aggregated assistant text,
|
||||||
* them to Workspace-relative paths, confirms they actually exist via files/stat, and aggregates
|
* while nested conversations — which don't produce task_stats — pass one settled assistant
|
||||||
* them into a unified card at the end of the message — a light-background single-line header bar
|
* message to preserve their existing behavior. Extracts inline-code paths heuristically via
|
||||||
* ("N files") + a list of file rows inside the card; each row's path is split into a "faded
|
* isFilePathLike, normalizes them to Workspace-relative paths, confirms they actually exist via
|
||||||
* directory / bold filename" pair, with a "Preview" label at the end of the row making the action
|
* files/stat, and renders a light-background "N files" card whose rows open the Files panel.
|
||||||
* explicit, and clicking the whole row navigates to the Files panel preview for that relative
|
* Collapses when there are more than 3 rows.
|
||||||
* path via onOpenFile. Collapses when there are more than 3 rows.
|
*
|
||||||
* The whole card doesn't render until the stat result comes back (to avoid a flash-then-disappear);
|
* The card waits for stat results and doesn't render when no candidate exists. It intentionally
|
||||||
* it also doesn't render if none of the candidates exist — the heuristic extraction inevitably
|
* does not claim these files were changed: opaque exec_command shells provide no reliable
|
||||||
* matches error message examples, external paths, and other strings that can't actually be
|
* structured edit signal, so this is only an aggregated view of text references that are
|
||||||
* opened, so this card is only responsible for "if you click it, it really opens".
|
* currently openable.
|
||||||
* Doesn't include diff stats — file writes may happen inside opaque exec_command shells, so
|
|
||||||
* the protocol has no reliable structured edit signal; this is just an aggregated view of
|
|
||||||
* text references, hence the neutral "N files" title.
|
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { S } from "../../lib/strings";
|
import { S } from "../../lib/strings";
|
||||||
@@ -61,7 +58,7 @@ export function MessageFilesCard({
|
|||||||
statFiles,
|
statFiles,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: {
|
}: {
|
||||||
/** Raw Markdown text of the assistant message. */
|
/** Raw Markdown from the assistant scope being summarized (a root Task or one nested message). */
|
||||||
text: string;
|
text: string;
|
||||||
/** Absolute Workspace path of the current Session (used to normalize absolute paths found in the text). */
|
/** Absolute Workspace path of the current Session (used to normalize absolute paths found in the text). */
|
||||||
workspace: string | null;
|
workspace: string | null;
|
||||||
|
|||||||
@@ -328,8 +328,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
|||||||
{item.stopReason && item.stopReason !== "completed" && (
|
{item.stopReason && item.stopReason !== "completed" && (
|
||||||
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
|
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
|
||||||
)}
|
)}
|
||||||
{/* File summary card (Codex-style): aggregates file references in the text once streaming ends (lists only ones confirmed to exist). */}
|
{/* Nested models don't produce task_stats, so preserve their existing message-level file summaries. The root conversation renders one aggregated card from task_stats instead. */}
|
||||||
{!item.streaming && ctx.onOpenFile && ctx.statFiles && (
|
{ctx.origin.length > 0 && !item.streaming && ctx.onOpenFile && ctx.statFiles && (
|
||||||
<MessageFilesCard
|
<MessageFilesCard
|
||||||
text={item.text}
|
text={item.text}
|
||||||
workspace={ctx.workspace ?? null}
|
workspace={ctx.workspace ?? null}
|
||||||
@@ -363,12 +363,26 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
|||||||
return <CompactionBanner item={item} />;
|
return <CompactionBanner item={item} />;
|
||||||
case "task_stats":
|
case "task_stats":
|
||||||
return (
|
return (
|
||||||
<TaskStatsLine
|
<>
|
||||||
stats={item.stats}
|
{/* Root-session file references are a Task-level summary: task_stats is emitted only after the Task closes, and assistantText aggregates every assistant segment in that Task. */}
|
||||||
assistantText={item.assistantText}
|
{ctx.origin.length === 0 &&
|
||||||
cost={item.stats ? (ctx.taskCost?.(item.stats) ?? null) : null}
|
ctx.onOpenFile &&
|
||||||
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
|
ctx.statFiles &&
|
||||||
/>
|
item.assistantText.trim() !== "" && (
|
||||||
|
<MessageFilesCard
|
||||||
|
text={item.assistantText}
|
||||||
|
workspace={ctx.workspace ?? null}
|
||||||
|
statFiles={ctx.statFiles}
|
||||||
|
onOpenFile={ctx.onOpenFile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TaskStatsLine
|
||||||
|
stats={item.stats}
|
||||||
|
assistantText={item.assistantText}
|
||||||
|
cost={item.stats ? (ctx.taskCost?.(item.stats) ?? null) : null}
|
||||||
|
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ import type { MessagesLiveTail, ServerEvent, SessionStatus } from "@prismshadow/
|
|||||||
import { createStreamController } from "../src/lib/omni/stream-controller";
|
import { createStreamController } from "../src/lib/omni/stream-controller";
|
||||||
import type { StreamController } from "../src/lib/omni/stream-controller";
|
import type { StreamController } from "../src/lib/omni/stream-controller";
|
||||||
import { approvalKey, findToolCard } from "../src/lib/omni/stream-model";
|
import { approvalKey, findToolCard } from "../src/lib/omni/stream-model";
|
||||||
import type { AssistantTextItem, ToolCallItem } from "../src/lib/omni/stream-model";
|
import type { AssistantTextItem, TaskStatsItem, ToolCallItem } from "../src/lib/omni/stream-model";
|
||||||
|
|
||||||
/** Override a message timestamp (constructor defaults to the current time). */
|
/** Override a message timestamp (constructor defaults to the current time). */
|
||||||
function at<M extends OmniMessage>(msg: M, ts: string): M {
|
function at<M extends OmniMessage>(msg: M, ts: string): M {
|
||||||
@@ -172,6 +172,28 @@ describe("in-stream task_state is the authoritative running state (history-closi
|
|||||||
// History hasn't returned yet, but state is already reported.
|
// History hasn't returned yet, but state is already reported.
|
||||||
expect(h.states).toEqual(["running"]);
|
expect(h.states).toEqual(["running"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes the current Task before an auto-started queued follow-up begins", async () => {
|
||||||
|
const h = createHarness();
|
||||||
|
const p = h.controller.load();
|
||||||
|
h.controller.handleServer({ type: "task_state", state: "running", queued: 1 });
|
||||||
|
h.resolveLoad(HISTORY_TASK);
|
||||||
|
await p;
|
||||||
|
|
||||||
|
// Server ordering for a queued follow-up: current run flips idle, then launchTask publishes
|
||||||
|
// the queued user input before its running state. The first idle must seal Task 1 before that.
|
||||||
|
h.controller.handleServer({ type: "task_state", state: "idle", queued: 1 });
|
||||||
|
h.controller.handleOmni(at(userText("follow-up"), "2026-07-05T00:01:00.000Z"));
|
||||||
|
h.controller.handleServer({ type: "task_state", state: "running", queued: 0 });
|
||||||
|
h.controller.handleOmni(at(assistantText("follow-up answer"), "2026-07-05T00:01:03.000Z"));
|
||||||
|
h.controller.handleOmni(at(tokenUsage(counts(1400), counts(400)), "2026-07-05T00:01:05.000Z"));
|
||||||
|
h.controller.handleServer({ type: "task_state", state: "idle", queued: 0 });
|
||||||
|
|
||||||
|
const stats = h.controller.model.items.filter(
|
||||||
|
(item) => item.kind === "task_stats",
|
||||||
|
) as TaskStatsItem[];
|
||||||
|
expect(stats.map((item) => item.assistantText)).toEqual(["answer", "follow-up answer"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("approval re-delivery (origin composite key + missing-card backfill)", () => {
|
describe("approval re-delivery (origin composite key + missing-card backfill)", () => {
|
||||||
|
|||||||
@@ -788,6 +788,22 @@ describe("Task segmentation and stats triggering", () => {
|
|||||||
expect(stats.stats!.elapsedDeltaMs).toBe(5000); // time span from the first to the last message
|
expect(stats.stats!.elapsedDeltaMs).toBe(5000); // time span from the first to the last message
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aggregates every assistant text segment in a Task into the footer copy target", () => {
|
||||||
|
const m = createStreamModel();
|
||||||
|
pushMessages(m, [
|
||||||
|
at(userText("build it"), "2026-07-05T00:00:00.000Z"),
|
||||||
|
at(assistantText("Creating `package.json`."), "2026-07-05T00:00:01.000Z"),
|
||||||
|
at(tokenUsage(counts(400), counts(400)), "2026-07-05T00:00:02.000Z"),
|
||||||
|
at(assistantText("Installation finished."), "2026-07-05T00:00:03.000Z"),
|
||||||
|
at(tokenUsage(counts(700), counts(300)), "2026-07-05T00:00:04.000Z"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
finalizeHistory(m);
|
||||||
|
|
||||||
|
const stats = items(m).find((i) => i.kind === "task_stats") as TaskStatsItem;
|
||||||
|
expect(stats.assistantText).toBe("Creating `package.json`.\n\nInstallation finished.");
|
||||||
|
});
|
||||||
|
|
||||||
it("stream end (finalizeHistory) closes the last Task; rounds without usage get no stats figures but still get a footer", () => {
|
it("stream end (finalizeHistory) closes the last Task; rounds without usage get no stats figures but still get a footer", () => {
|
||||||
const m = createStreamModel();
|
const m = createStreamModel();
|
||||||
pushMessages(m, [
|
pushMessages(m, [
|
||||||
|
|||||||
Reference in new issue
Block a user