Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/web/src/features/transcript/TranscriptRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -609,15 +609,19 @@ function UnknownEntryRow({
function WorkingRow({
row,
nowMs,
ghost,
}: {
readonly row: Extract<TranscriptRow, { kind: "working" }>;
readonly nowMs: number;
readonly ghost: boolean;
}) {
const compacting = row.activity === "preparing-context";
return (
<div
className="flex items-center gap-2 py-3 text-status-working text-xs"
data-transcript-status={compacting ? "compacting-context" : "working"}
// The live status is a singleton: a paint-only ghost copy (cold-mount
// overlay) must never duplicate the semantic lifecycle hook.
data-transcript-status={ghost ? undefined : compacting ? "compacting-context" : "working"}
>
<LoaderCircle
aria-hidden="true"
Expand Down Expand Up @@ -653,12 +657,19 @@ export const TranscriptRowContent = memo(function TranscriptRowContent({
nowMs,
imageSource,
toolHost,
ghost = false,
}: {
readonly row: TranscriptRow;
/** Elapsed-label time base from the session runtime snapshot. */
readonly nowMs: number;
readonly imageSource: TranscriptImageSource;
readonly toolHost?: ToolRenderHost | undefined;
/**
* Paint-only duplicate of a row (the cold-mount overlay's warm copy).
* A ghost renders pixel-identical but never carries singleton semantic
* hooks — the live transcript copy is the only semantic instance.
*/
readonly ghost?: boolean | undefined;
}) {
switch (row.kind) {
case "message":
Expand All @@ -672,6 +683,6 @@ export const TranscriptRowContent = memo(function TranscriptRowContent({
case "unknown-entry":
return <UnknownEntryRow row={row} />;
case "working":
return <WorkingRow nowMs={nowMs} row={row} />;
return <WorkingRow ghost={ghost} nowMs={nowMs} row={row} />;
}
});
1 change: 1 addition & 0 deletions apps/web/src/features/transcript/TranscriptTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({
key={row.id}
>
<TranscriptRowContent
ghost
imageSource={imageSource}
nowMs={nowMs}
row={row}
Expand Down
36 changes: 34 additions & 2 deletions apps/web/test/compaction-status.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ const IMAGE_SOURCE: TranscriptImageSource = {
dispose: () => undefined,
};

function renderWorkingRow(projection: TranscriptProjection, nowMs: number): string | null {
function renderWorkingRow(
projection: TranscriptProjection,
nowMs: number,
options?: { readonly ghost?: boolean },
): string | null {
const row = deriveTranscriptRows(projection).find((candidate) => candidate.kind === "working");
if (row?.kind !== "working") return null;
return renderToStaticMarkup(
<TranscriptRowContent imageSource={IMAGE_SOURCE} nowMs={nowMs} row={row} />,
<TranscriptRowContent
ghost={options?.ghost ?? false}
imageSource={IMAGE_SOURCE}
nowMs={nowMs}
row={row}
/>,
);
}

Expand Down Expand Up @@ -87,4 +96,27 @@ describe("compaction transcript status", () => {
expect(markup).toContain("4s");
expect(markup).not.toContain("Compacting context");
});

it("keeps the lifecycle hook off paint-only ghost copies so one semantic row exists", () => {
const factory = new FrameFactory({ host: "host", session: "session", epoch: "epoch" });
let projection = projectionWithSnapshot(factory);
projection = reduceTranscript(
projection,
factory.event({
type: "compaction.start",
reason: "pending_prompt_size",
at: "2026-07-15T20:00:01Z",
}),
);

const nowMs = Date.parse("2026-07-15T20:00:06Z");
const ghost = renderWorkingRow(projection, nowMs, { ghost: true });
expect(ghost).not.toBeNull();
// The ghost paints the same copy but never duplicates the semantic hook.
expect(ghost).not.toContain("data-transcript-status");
expect(ghost).toContain("Compacting context for ");
expect(renderWorkingRow(projection, nowMs)).toContain(
'data-transcript-status="compacting-context"',
);
});
});
4 changes: 2 additions & 2 deletions provenance/t3code/imports/f2-transcript-20260711.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"sourceBlobSha": "1a4dc6b689558a9f8d834c12d7ecb2bb64e0d7c8",
"targetPath": "apps/web/src/features/transcript/TranscriptTimeline.tsx",
"classification": "adapted",
"checksum": "sha256:1f302713aebae1d70fe4509a2107a35fa4827e47016968976f322b345446b420"
"checksum": "sha256:83148bec99a0d57d47a5f239d0b2dc904b8696315ef6d0e7149d773234a12c7f"
},
{
"sourcePath": "apps/web/src/components/chat/MessagesTimeline.tsx",
Expand All @@ -23,7 +23,7 @@
"sourceBlobSha": "1a4dc6b689558a9f8d834c12d7ecb2bb64e0d7c8",
"targetPath": "apps/web/src/features/transcript/TranscriptRows.tsx",
"classification": "adapted",
"checksum": "sha256:fd24741fc543e280d2d03295da35d246ece68c6614aa17903ebc53a9bb1e3ad4"
"checksum": "sha256:e8fe7580c871464d2389099468c786cc9c90b669171b641e27223e7894d57784"
},
{
"sourcePath": "packages/shared/src/searchRanking.ts;apps/web/src/components/chat/composerSlashCommandSearch.ts",
Expand Down
Loading