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:
Youhai
2026-07-31 20:20:58 +08:00
committed by GitHub
parent 4be0262381
commit 4db89cbe3f
7 changed files with 108 additions and 51 deletions
+29 -26
View File
@@ -215,6 +215,13 @@ function headerStats(
*/
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() {
const navigate = useNavigate();
const params = useParams<{ sessionId?: string }>();
@@ -451,10 +458,10 @@ export function ChatPage() {
}
}, [stream.taskState, reloadSessions, reloadAgents]);
// Existence cache for message file cards (session-level): normalized relative path -> whether
// it exists; while a lookup is in flight, the cache shares a single Promise, so a batch of
// concurrent mounts only issues one files/stat call.
const statCacheRef = useRef(new Map<string, boolean | Promise<boolean>>());
// Positive-only existence cache for file summary cards (session-level): normalized relative
// path -> true, or the shared in-flight lookup. Missing files aren't retained — a later Task may
// create the same path, so its summary must re-check instead of inheriting stale false state.
const statCacheRef = useRef(new Map<string, true | Promise<boolean>>());
// 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
@@ -466,34 +473,30 @@ export function ChatPage() {
statCacheRef.current = new Map();
}, [routeSessionId]);
// Batched existence check (message file cards): merges only the cache-miss paths into a single
// files/stat call, and the result lands in the session-level cache — during streaming, the
// candidate set is re-checked on every change, and the cache ensures only new paths trigger a
// 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.
// Batched existence check for file summaries: cache stable positive results and share in-flight
// requests, but never retain a negative result. Each pending lookup mutates the cache only while
// it is still the current entry, so an old request can't delete or overwrite a newer one.
const statFiles = useCallback(
async (paths: string[]): Promise<ReadonlySet<string>> => {
const sessionId = selected?.sessionId ?? null;
const cache = statCacheRef.current;
const misses = sessionId === null ? [] : paths.filter((p) => !cache.has(p));
if (sessionId !== null && misses.length > 0) {
const batch = api
.statSessionFiles(sessionId, misses)
.then((res) => new Set(res.existing))
.catch(() => null);
for (const p of misses) {
cache.set(
p,
batch.then((existing) => {
if (existing === null) {
cache.delete(p);
return false;
}
const exists = existing.has(p);
cache.set(p, exists);
return exists;
}),
);
for (let i = 0; i < misses.length; i += STAT_PATHS_PER_REQUEST) {
const chunk = misses.slice(i, i + STAT_PATHS_PER_REQUEST);
const batch = api
.statSessionFiles(sessionId, chunk)
.then((res) => new Set(res.existing))
.catch(() => null);
for (const p of chunk) {
const pending = batch.then((existing) => existing?.has(p) ?? false);
cache.set(p, pending);
void pending.then((exists) => {
if (cache.get(p) !== pending) return;
if (exists) cache.set(p, true);
else cache.delete(p);
});
}
}
}
const result = new Set<string>();
@@ -1,19 +1,16 @@
/**
* Message-level file summary card (visual reference: Codex's "files changed" card): extracts
* file paths from inline code in the assistant's text (heuristic via isFilePathLike), normalizes
* them to Workspace-relative paths, confirms they actually exist via files/stat, and aggregates
* them into a unified card at the end of the message — a light-background single-line header bar
* ("N files") + a list of file rows inside the card; each row's path is split into a "faded
* directory / bold filename" pair, with a "Preview" label at the end of the row making the action
* explicit, and clicking the whole row navigates to the Files panel preview for that relative
* 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);
* it also doesn't render if none of the candidates exist — the heuristic extraction inevitably
* matches error message examples, external paths, and other strings that can't actually be
* opened, so this card is only responsible for "if you click it, it really opens".
* 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.
* File summary card for a supplied assistant-text scope (visual reference: Codex's "files
* changed" card): the root conversation passes a completed Task's aggregated assistant text,
* while nested conversations — which don't produce task_stats — pass one settled assistant
* message to preserve their existing behavior. Extracts inline-code paths heuristically via
* isFilePathLike, normalizes them to Workspace-relative paths, confirms they actually exist via
* files/stat, and renders a light-background "N files" card whose rows open the Files panel.
* Collapses when there are more than 3 rows.
*
* The card waits for stat results and doesn't render when no candidate exists. It intentionally
* does not claim these files were changed: opaque exec_command shells provide no reliable
* structured edit signal, so this is only an aggregated view of text references that are
* currently openable.
*/
import { useEffect, useMemo, useState } from "react";
import { S } from "../../lib/strings";
@@ -61,7 +58,7 @@ export function MessageFilesCard({
statFiles,
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;
/** Absolute Workspace path of the current Session (used to normalize absolute paths found in the text). */
workspace: string | null;
@@ -328,8 +328,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
{item.stopReason && item.stopReason !== "completed" && (
<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). */}
{!item.streaming && ctx.onOpenFile && ctx.statFiles && (
{/* 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. */}
{ctx.origin.length > 0 && !item.streaming && ctx.onOpenFile && ctx.statFiles && (
<MessageFilesCard
text={item.text}
workspace={ctx.workspace ?? null}
@@ -363,12 +363,26 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
return <CompactionBanner item={item} />;
case "task_stats":
return (
<TaskStatsLine
stats={item.stats}
assistantText={item.assistantText}
cost={item.stats ? (ctx.taskCost?.(item.stats) ?? null) : null}
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
/>
<>
{/* 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. */}
{ctx.origin.length === 0 &&
ctx.onOpenFile &&
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 } : {})}
/>
</>
);
}
}