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