mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
fix(transcript): redact bodies before anything clips them (MUL-7227)
Review of #8242 found that deriving the inline timeline unredacted put raw bodies in front of clips that redact afterwards, and a pattern only matches while both its ends are present. `StepBody` cut at its display clip and then redacted; a step summary cut to 200 characters and then redacted. A secret straddling either cut lost the tail its pattern needed and rendered its head. The whole-transcript redaction this branch removed had been hiding that ordering, in the dialog and everywhere else. Redaction now happens where it did before — over a whole body, before any cut — but on the bodies being rendered instead of on every message in the transcript. `redactTraceRow` / `redactTraceStep` redact the rows on screen and the one step the collapsed summary reads; `StepBody` redacts, then clips. The bound is what this branch is for: a live run rebuilding on each 100ms flush pays for a dozen rows, not for thousands of messages. Measured on the same chain as before: ~15-25ms per flush at 3000 messages, now ~0.2ms. Also closes a leak the review turned up while auditing that path, older than this branch: `DiffDetailSurface` handed raw lines to the syntax highlighter and rendered its markup with `dangerouslySetInnerHTML`, so `redactSecrets` only ever ran on the fallback — redaction was conditional on a language lookup failing. Diff lines are built from tool input too, which carries no upstream redaction at all, so an `Edit` to a recognized file type displayed whatever the agent wrote. Lines are redacted once, before highlighting. Second finding: removing `refetchOnWindowFocus` left the initial handshake uncovered. The transcript fetch and the WS subscription are not ordered, so a row persisted between them is in neither the response nor any frame this client received, and `staleTime: Infinity` means nothing fetches it again — the run stays live and visible, so no mount or liveness transition fires either. `onReconnect` deliberately skips the first connection, which is exactly the one that matters here. `WSClient.onConnect` fires on every authenticated connection, and the sync layer uses it to invalidate `task-messages` alone — once per connection rather than on every window focus. Six regression tests, each verified to fail without its fix. The redaction ones render the real `StepBody` and detail surfaces; the inline suite mocks them, which is why these boundaries were missed. Verified: pnpm typecheck, pnpm lint (0 errors), pnpm test — core 1724, views 4995, desktop 626, web 266, docs 15. Reported-by: Niko Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
@@ -48,6 +48,7 @@ export class WSClient {
|
||||
// on each connect() so a fresh connection logs once again.
|
||||
private badFrameLogged = false;
|
||||
private onReconnectCallbacks = new Set<() => void>();
|
||||
private onConnectCallbacks = new Set<() => void>();
|
||||
private anyHandlers = new Set<(msg: WSMessage) => void>();
|
||||
private logger: Logger;
|
||||
|
||||
@@ -192,6 +193,13 @@ export class WSClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const cb of this.onConnectCallbacks) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
// ignore connect callback errors
|
||||
}
|
||||
}
|
||||
this.hasConnectedBefore = true;
|
||||
}
|
||||
|
||||
@@ -212,6 +220,7 @@ export class WSClient {
|
||||
this.handlers.clear();
|
||||
this.anyHandlers.clear();
|
||||
this.onReconnectCallbacks.clear();
|
||||
this.onConnectCallbacks.clear();
|
||||
}
|
||||
|
||||
on(event: WSEventType, handler: EventHandler) {
|
||||
@@ -238,6 +247,23 @@ export class WSClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every authenticated connection, including the first one.
|
||||
*
|
||||
* `onReconnect` skips the first deliberately: a client that has never been
|
||||
* subscribed has no missed events to recover. That reasoning does not hold
|
||||
* for data this client read over HTTP before the socket came up — the fetch
|
||||
* and the subscription are not ordered, so a row persisted in between is
|
||||
* broadcast to nobody here and is absent from the response. Consumers whose
|
||||
* only live source is this socket need the first connection too.
|
||||
*/
|
||||
onConnect(callback: () => void) {
|
||||
this.onConnectCallbacks.add(callback);
|
||||
return () => {
|
||||
this.onConnectCallbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
send(message: WSMessage) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
|
||||
@@ -37,6 +37,7 @@ function createRecordingWs(): {
|
||||
}),
|
||||
onAny: vi.fn(() => () => {}),
|
||||
onReconnect: vi.fn(() => () => {}),
|
||||
onConnect: vi.fn(() => () => {}),
|
||||
} as unknown as WSClient;
|
||||
return { ws, handlers };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api";
|
||||
import { WSClient } from "../api/ws-client";
|
||||
import { useTaskMessages } from "../chat/queries";
|
||||
import type { TaskMessagePayload } from "../types/events";
|
||||
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
|
||||
|
||||
vi.mock("../api", () => ({ api: { listTaskMessages: vi.fn() } }));
|
||||
vi.mock("../platform/workspace-storage", () => ({
|
||||
getCurrentWsId: () => "ws-1", getCurrentSlug: () => "test-ws",
|
||||
createWorkspaceAwareStorage: (adapter: unknown) => adapter,
|
||||
registerForWorkspaceRehydration: () => {},
|
||||
}));
|
||||
vi.mock("../paths", () => ({ useHasOnboarded: () => true, resolvePostAuthDestination: () => "/" }));
|
||||
|
||||
class FakeWebSocket {
|
||||
static last: FakeWebSocket;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
constructor() { FakeWebSocket.last = this; }
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const msg = (seq: number): TaskMessagePayload => ({ task_id: id, issue_id: "issue-1", seq, type: "text", content: `fragment ${seq}` });
|
||||
const stores = { authStore: Object.assign(() => ({}), {
|
||||
getState: () => ({ user: { id: "u1" } }), subscribe: () => () => {}, setState: () => {},
|
||||
}) } as unknown as RealtimeSyncStores;
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
|
||||
|
||||
describe("timeline repair on connect", () => {
|
||||
it("recovers a row persisted between the transcript fetch and the first handshake", async () => {
|
||||
// The fetch and the subscription are not ordered. seq 2 is written while
|
||||
// the socket is still connecting, so the response never saw it and its
|
||||
// broadcast reached nobody here. `staleTime: Infinity` means nothing would
|
||||
// fetch it again, and the run stays live and visible throughout — so no
|
||||
// mount or liveness transition fires either. Only the connection itself
|
||||
// marks the point where this client's view could have a hole in it.
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
const ws = new WSClient("ws://example.test/ws", { cookieAuth: true });
|
||||
ws.connect();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
|
||||
vi.mocked(api.listTaskMessages).mockResolvedValue([msg(1)]);
|
||||
const { result, unmount } = renderHook(
|
||||
() => { useRealtimeSync(ws, stores); return useTaskMessages(id, true); },
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.data?.map((m) => m.seq)).toEqual([1]));
|
||||
|
||||
vi.mocked(api.listTaskMessages).mockResolvedValue([msg(1), msg(2), msg(3)]);
|
||||
act(() => {
|
||||
FakeWebSocket.last.onopen?.();
|
||||
FakeWebSocket.last.onmessage?.({ data: JSON.stringify({ type: "task:message", payload: msg(3) }) });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.data?.map((m) => m.seq)).toEqual([1, 2, 3]));
|
||||
unmount(); ws.disconnect(); qc.clear();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ function createMockWs(handlers: Handlers): WSClient {
|
||||
}),
|
||||
onAny: vi.fn(() => () => {}),
|
||||
onReconnect: vi.fn(() => () => {}),
|
||||
onConnect: vi.fn(() => () => {}),
|
||||
} as unknown as WSClient;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ function createMockWs(): WSClient {
|
||||
on: vi.fn(() => () => {}),
|
||||
onAny: vi.fn(() => () => {}),
|
||||
onReconnect: vi.fn(() => () => {}),
|
||||
onConnect: vi.fn(() => () => {}),
|
||||
} as unknown as WSClient;
|
||||
}
|
||||
|
||||
|
||||
@@ -1746,6 +1746,23 @@ export function useRealtimeSync(
|
||||
};
|
||||
}, [ws, qc, authStore, onToast]);
|
||||
|
||||
// Connect -> repair timelines fetched before this client was subscribed.
|
||||
//
|
||||
// `taskMessagesOptions` holds `staleTime: Infinity` and is kept current by
|
||||
// `task:message` frames, so a row persisted between the HTTP read and the
|
||||
// socket coming up is in neither: the response was snapshotted before it
|
||||
// existed, and its broadcast went out while nobody here was listening.
|
||||
// Removing the focus refetch (MUL-7227) took away the accident that used to
|
||||
// paper over this, so it is repaired where it happens instead — once per
|
||||
// connection, and only for the caches whose sole live source is the socket,
|
||||
// rather than on every window focus.
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
return ws.onConnect(() => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
|
||||
});
|
||||
}, [ws, qc]);
|
||||
|
||||
// Reconnect -> refetch all data to recover missed events
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
@@ -1796,10 +1796,15 @@ export function StepBody({ item }: { item: TimelineItem }) {
|
||||
// server-side budget and keeps the clip at its existing length; nothing
|
||||
// about how long an input renders is this change's business.
|
||||
const clip = item.type === "tool_result" ? null : DISPLAY_CLIP_CHARS;
|
||||
// Redact the whole body, then clip it. The other order drops the tail a
|
||||
// pattern needs to match, so a secret straddling the clip renders its
|
||||
// head (MUL-7227 review) — and the clip length is a display choice that
|
||||
// must not decide what stays hidden.
|
||||
const safe = redactSecrets(text);
|
||||
const clipped =
|
||||
clip !== null && text.length > clip
|
||||
? `${redactSecrets(text.slice(0, clip))}\n${t(($) => $.transcript.display_clipped)}`
|
||||
: redactSecrets(text);
|
||||
clip !== null && safe.length > clip
|
||||
? `${safe.slice(0, clip)}\n${t(($) => $.transcript.display_clipped)}`
|
||||
: safe;
|
||||
const path = item.type === "tool_use" ? readPathFromInput(item.input) : undefined;
|
||||
return (
|
||||
<ToolDetailSurface
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TimelineItem } from "./build-timeline";
|
||||
import { redactTimelineItem, type TimelineItem } from "./build-timeline";
|
||||
|
||||
/**
|
||||
* A run's steps, derived from the flat event stream.
|
||||
@@ -192,6 +192,35 @@ export function groupSteps(steps: TraceStep[]): TraceRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact every body this row can put on screen.
|
||||
*
|
||||
* Applied to the rows actually being rendered rather than to the whole
|
||||
* transcript: scanning every message on each live flush is what made the
|
||||
* client stall (MUL-7227), and the rows on screen are a bounded slice of it.
|
||||
* What must not change is the order — a row is redacted whole, before its
|
||||
* summary is cut to 200 characters and before `StepBody` reaches its display
|
||||
* clip, because a secret straddling either cut cannot be matched afterwards.
|
||||
*/
|
||||
export function redactTraceRow(row: TraceRow): TraceRow {
|
||||
if (isGroupRow(row)) return { ...row, steps: row.steps.map(redactCallStep) };
|
||||
return redactTraceStep(row);
|
||||
}
|
||||
|
||||
/** `redactTraceRow` for a single step. */
|
||||
export function redactTraceStep(step: TraceStep): TraceStep {
|
||||
if (step.kind === "call") return redactCallStep(step);
|
||||
return { ...step, item: redactTimelineItem(step.item) };
|
||||
}
|
||||
|
||||
function redactCallStep(step: TraceCallStep): TraceCallStep {
|
||||
return {
|
||||
...step,
|
||||
call: step.call && redactTimelineItem(step.call),
|
||||
result: step.result && redactTimelineItem(step.result),
|
||||
};
|
||||
}
|
||||
|
||||
// `TraceMessageStep` carries three kinds on one interface, so a `kind` check
|
||||
// alone narrows the property without dropping the constituent. These predicates
|
||||
// are what let callers switch on a row and get a usable type back.
|
||||
|
||||
@@ -58,11 +58,25 @@ export function appendTimelineItem(items: TimelineItem[], item: TimelineItem): T
|
||||
* actually render should build the structure and skip this.
|
||||
*/
|
||||
export function redactTimelineItems(items: TimelineItem[]): TimelineItem[] {
|
||||
return items.map((item) => ({
|
||||
return items.map(redactTimelineItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact one record's bodies.
|
||||
*
|
||||
* Whole bodies, before anything downstream clips or summarizes them. Every
|
||||
* consumer of a body cuts first and redacts second — `StepBody` at its display
|
||||
* clip, the step summaries at 200 characters — so a pattern that spans the cut
|
||||
* loses the tail it needs to match and the head renders. Redacting the record
|
||||
* first is what made that ordering harmless, and it stays harmless as long as
|
||||
* a raw record never reaches a renderer.
|
||||
*/
|
||||
export function redactTimelineItem(item: TimelineItem): TimelineItem {
|
||||
return {
|
||||
...item,
|
||||
content: item.content ? redactSecrets(item.content) : item.content,
|
||||
output: item.output ? redactSecrets(item.output) : item.output,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -178,6 +178,17 @@ export function DiffDetailSurface({ lines, path }: { lines: TraceDiffLine[]; pat
|
||||
const added = lines.filter((l) => l.kind === "add").length;
|
||||
const removed = lines.filter((l) => l.kind === "remove").length;
|
||||
|
||||
// Redacted here, before highlighting, rather than only in the fallback below.
|
||||
// The highlighter returns markup that is rendered with
|
||||
// `dangerouslySetInnerHTML`, so whatever it is handed reaches the screen
|
||||
// without passing the fallback's `redactSecrets` — which made the redaction
|
||||
// conditional on a language lookup failing. Diff lines are also built from
|
||||
// tool input, which carries no upstream redaction at all.
|
||||
const safeLines = useMemo(
|
||||
() => lines.map((line) => (line.text ? { ...line, text: redactSecrets(line.text) } : line)),
|
||||
[lines],
|
||||
);
|
||||
|
||||
// Each side is highlighted as one block so multi-line strings and comments
|
||||
// keep their grammar, then split back per line to sit in the diff gutter.
|
||||
// Runs only when the row is expanded, which is where this component mounts.
|
||||
@@ -190,13 +201,13 @@ export function DiffDetailSurface({ lines, path }: { lines: TraceDiffLine[]; pat
|
||||
context: null,
|
||||
};
|
||||
for (const kind of ["add", "remove", "context"] as const) {
|
||||
const side = lines.filter((l) => l.kind === kind);
|
||||
const side = safeLines.filter((l) => l.kind === kind);
|
||||
if (side.length > 0) {
|
||||
sides[kind] = highlightToLines(side.map((l) => l.text).join("\n"), language);
|
||||
}
|
||||
}
|
||||
return sides;
|
||||
}, [lines, path]);
|
||||
}, [safeLines, path]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -210,7 +221,7 @@ export function DiffDetailSurface({ lines, path }: { lines: TraceDiffLine[]; pat
|
||||
isLong && !showAll && "max-h-52 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{renderDiffRows(lines, highlighted)}
|
||||
{renderDiffRows(safeLines, highlighted)}
|
||||
</pre>
|
||||
{isLong && !showAll && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex h-12 items-end justify-center rounded-b-md bg-gradient-to-b from-transparent to-background">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { AgentTask } from "@multica/core/types";
|
||||
import type { TaskMessagePayload } from "@multica/core/types/events";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
import { InlineCommentRun } from "../../issues/components/inline-comment-run";
|
||||
import { StepBody } from "./agent-transcript-dialog";
|
||||
import { buildTimelineStructure } from "./build-timeline";
|
||||
|
||||
/**
|
||||
* Redaction must run over a whole body before anything cuts it down.
|
||||
*
|
||||
* These bodies reach the screen through several clips — `StepBody`'s display
|
||||
* clip, the 200-character step summary — and a pattern only matches while both
|
||||
* of its ends are present. Cutting first and redacting second therefore hides
|
||||
* nothing and renders the head of the secret. The inline run deriving its
|
||||
* timeline unredacted (MUL-7227) is what put raw bodies in front of those
|
||||
* clips, so each case here pins the order rather than the redaction.
|
||||
*
|
||||
* Deliberately renders the real `StepBody` and the real detail surfaces: the
|
||||
* inline suite mocks them, which is exactly why these boundaries were missed.
|
||||
*/
|
||||
vi.mock("@multica/core/api", () => ({ api: {
|
||||
getIssue: vi.fn(), listTaskMessages: vi.fn(), cancelTask: vi.fn(), rerunIssue: vi.fn(),
|
||||
}, dispatchReasonCode: () => undefined }));
|
||||
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace" }));
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({ useActorName: () => ({ getActorName: () => "Reviewer" }) }));
|
||||
vi.mock("../actor-avatar", () => ({ ActorAvatar: () => <span /> }));
|
||||
vi.mock("./use-trace-issue-labels", () => ({ useTraceIssueLabels: () => (text: string) => text }));
|
||||
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
const id = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
|
||||
const fakeKey = "AKIA1234567890ABCDEF";
|
||||
const task: AgentTask = {
|
||||
id, agent_id: "agent", runtime_id: "runtime", issue_id: "issue", status: "running", priority: 0,
|
||||
created_at: "2026-09-07T00:00:00Z", started_at: "2026-09-07T00:00:00Z", dispatched_at: null,
|
||||
completed_at: null, result: null, error: null,
|
||||
};
|
||||
const item = (msgs: TaskMessagePayload[]) => buildTimelineStructure(msgs)[0]!;
|
||||
|
||||
describe("redaction runs before every clip", () => {
|
||||
it.each(["text", "thinking"] as const)("hides a %s secret straddling the display clip", (type) => {
|
||||
// Split across two frames so it only exists after coalescing, and placed so
|
||||
// the key spans the 8000-character clip.
|
||||
const view = renderWithI18n(<StepBody item={item([
|
||||
{ task_id: id, issue_id: "issue", seq: 1, type, content: "x".repeat(7989) + " " + fakeKey.slice(0, 10) },
|
||||
{ task_id: id, issue_id: "issue", seq: 2, type, content: fakeKey.slice(10) },
|
||||
])} />);
|
||||
|
||||
expect(view.container.textContent).not.toContain("AKIA123456");
|
||||
});
|
||||
|
||||
it("hides a PEM body whose closing marker falls past the display clip", () => {
|
||||
const material = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=";
|
||||
const keyStart = `-----BEGIN PRIVATE KEY-----\n${material}\n`;
|
||||
const view = renderWithI18n(<StepBody item={item([
|
||||
{ task_id: id, issue_id: "issue", seq: 1, type: "text", content: "x".repeat(7999 - keyStart.length) + "\n" + keyStart },
|
||||
{ task_id: id, issue_id: "issue", seq: 2, type: "text", content: "-----END PRIVATE KEY-----" },
|
||||
])} />);
|
||||
|
||||
expect(view.container.textContent).not.toContain(material);
|
||||
});
|
||||
|
||||
it("hides a secret past the 200-character cut in the inline step summary", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.mocked(api.listTaskMessages).mockResolvedValue([
|
||||
{ task_id: id, issue_id: "issue", seq: 1, type: "tool_result", tool: "exec_command", output: "x".repeat(189) + " " + fakeKey },
|
||||
]);
|
||||
const view = renderWithI18n(<QueryClientProvider client={client}>
|
||||
<InlineCommentRun run={{ task, commentId: "comment", hasReply: false }} />
|
||||
</QueryClientProvider>);
|
||||
|
||||
await screen.findByText("exec_command");
|
||||
fireEvent.click(screen.getByRole("button", { name: /View activity/ }));
|
||||
|
||||
const summary = view.container.querySelector("details summary");
|
||||
expect(summary?.textContent).not.toContain("AKIA123456");
|
||||
// The same string is also the hover title, which no clip protects.
|
||||
expect(summary?.querySelector("[title]")?.getAttribute("title")).not.toContain("AKIA123456");
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("hides a secret in a syntax-highlighted diff", () => {
|
||||
// The highlighter returns markup rendered with dangerouslySetInnerHTML, so
|
||||
// redaction that only runs on the un-highlighted fallback never applies to
|
||||
// a file whose language resolves. Built from tool input, which carries no
|
||||
// upstream redaction at all.
|
||||
const view = renderWithI18n(<StepBody item={item([
|
||||
{ task_id: id, issue_id: "issue", seq: 1, type: "tool_use", tool: "Edit",
|
||||
input: { file_path: "config.ts", old_string: "const value = 0;", new_string: `const value = '${fakeKey}';` } },
|
||||
])} />);
|
||||
|
||||
expect(view.container.querySelector(".hljs")).not.toBeNull();
|
||||
expect(view.container.textContent).not.toContain(fakeKey);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
import { UI_EASE_IN, UI_EASE_OUT, UI_MOTION_DURATION } from "@multica/ui/lib/motion";
|
||||
import { AgentTranscriptDialog, StepBody } from "../../common/task-transcript/agent-transcript-dialog";
|
||||
import { buildTimelineStructure, redactTimelineItems, type TimelineItem } from "../../common/task-transcript/build-timeline";
|
||||
import { buildSteps, groupSteps, isCallStep, isGroupRow, type TraceRow } from "../../common/task-transcript/build-steps";
|
||||
import { buildSteps, groupSteps, isCallStep, isGroupRow, redactTraceRow, redactTraceStep, type TraceRow } from "../../common/task-transcript/build-steps";
|
||||
import { traceEventSummary, traceToolArgSummary } from "../../common/task-transcript/trace-event-presenter";
|
||||
import { redactSecrets } from "../../common/task-transcript/redact";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
@@ -96,6 +96,13 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
|
||||
const formatText = useTraceIssueLabels(useWorkspaceId(), task.issue_id, items, loadTranscript);
|
||||
const steps = useMemo(() => buildSteps(items), [items]);
|
||||
const rows = useMemo(() => groupSteps(steps), [steps]);
|
||||
// Only the rows on screen are redacted, and they are redacted whole before
|
||||
// anything below cuts them. That bound is the point: the transcript this
|
||||
// slice comes from can be thousands of messages (MUL-7227).
|
||||
const visibleRows = useMemo(
|
||||
() => rows.slice(-visibleCount).map(redactTraceRow),
|
||||
[rows, visibleCount],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
@@ -111,7 +118,13 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
|
||||
const output = !hasReply ? commentRunOutput(task) : null;
|
||||
const latest = steps.findLast((step) => step.kind !== "text" || step.item.content?.trim());
|
||||
const pendingCall = steps.findLast((step) => isCallStep(step) && !step.result);
|
||||
const current = pendingCall ?? latest;
|
||||
// Redacted before it is summarized, not after: `traceToolArgSummary` and
|
||||
// friends cut to 200 characters, and a pattern spanning that cut cannot be
|
||||
// matched once the tail is gone. One step, so this stays off the hot path.
|
||||
const current = useMemo(() => {
|
||||
const step = pendingCall ?? latest;
|
||||
return step && redactTraceStep(step);
|
||||
}, [pendingCall, latest]);
|
||||
// Keep the last activity visible after a tool returns, until new progress arrives.
|
||||
const activitySummary = current && isCallStep(current)
|
||||
? redactSecrets(traceToolArgSummary(current.call?.input, { formatText }) || current.tool)
|
||||
@@ -202,7 +215,7 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
|
||||
{!isPending && !isError && rows.length === 0 && <p className="text-caption text-muted-foreground">{t(($) => $.inline_run.empty)}</p>}
|
||||
{rows.length > visibleCount && <button type="button" className="py-1 text-caption text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setVisibleCount((count) => count + 12)}>{t(($) => $.inline_run.show_earlier, { count: rows.length - visibleCount })}</button>}
|
||||
{rows.slice(-visibleCount).map((row) => <InlineStep key={row.seq} row={row} live={active} formatText={formatText} />)}
|
||||
{visibleRows.map((row) => <InlineStep key={row.seq} row={row} live={active} formatText={formatText} />)}
|
||||
<button type="button" className="flex items-center gap-1.5 rounded py-2 text-caption text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={openFullLog}>{t(($) => $.inline_run.full_log)}<ExternalLink className="size-3" /></button>
|
||||
</div>}
|
||||
|
||||
Reference in New Issue
Block a user