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
4 changes: 4 additions & 0 deletions changelog.d/chat-compact-clear-chips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- A Claude Code `/compact` sent from Chat says "Done" once the conversation is compacted, not as
soon as the command starts. After `/clear`, a command sent to the conversation left behind (such
as that `/compact`) no longer shows in the new, empty chat.
43 changes: 40 additions & 3 deletions scripts/chat-outcome-note-browser-qa.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Real ChatView for an OpenCode pane whose `@effect` send went as plain text: the outcome chip says
* "Runs in the terminal", and under it the note names the skill that was not attached, at 1280 and 390 px, light and dark. All traffic stays on this loopback fixture server.
* Then /compact and /clear: the new conversation shows no row of a send to the one it left.
*/
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
Expand All @@ -24,16 +25,18 @@ try {
writeFileSync(join(root, "index.html"), '<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module" src="/fixture.tsx"></script></body></html>');
await viteBuild({ configFile: join(repo, "vite.config.ts"), root, publicDir: false, logLevel: "warn", build: { outDir: output, emptyOutDir: true } });
const sentAt = new Date(Date.now() - 60_000).toISOString();
const turns: ConversationTurn[] = [{ role: "assistant", ts: new Date(Date.now() - 120_000).toISOString(), parts: [{ kind: "text", text: "Ready when you are." }] }];
const ready: ConversationTurn[] = [{ role: "assistant", ts: new Date(Date.now() - 120_000).toISOString(), parts: [{ kind: "text", text: "Ready when you are." }] }];
let turns = ready;
const outcome: CommandOutcome = {
id: "outcome-note-1", pane_id: "opencode-pane", text: "@effect apply it to the parser", state: "terminal-required", evidence: [],
note: "OpenCode did not attach @effect; it was sent as plain text", unattached_skills: ["effect"], created_at: sentAt, updated_at: sentAt,
};
let outcomes = [outcome], history: string | undefined;
server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/pane/prompt") return Response.json({ prompt: null, outcomes: [outcome] });
if (url.pathname === "/api/pane/prompt") return Response.json({ prompt: null, outcomes });
if (url.pathname === "/api/pane/commands") return Response.json({ commands: [{ name: "effect", description: "Apply Effect", source: "skill", trigger: "@", provenance: { transport: "native", verified: true } }] });
if (url.pathname === "/api/pane/conversation") return Response.json({ source: "opencode-transcript", cursor: null, turns });
if (url.pathname === "/api/pane/conversation") return Response.json({ source: "opencode-transcript", history_id: history, cursor: null, turns });
if (url.pathname === "/favicon.ico") return new Response(null, { status: 204 });
return new Response(Bun.file(join(output, url.pathname === "/" ? "index.html" : url.pathname.slice(1))), { headers: { "content-security-policy": CONTENT_SECURITY_POLICY } });
} });
Expand Down Expand Up @@ -68,6 +71,40 @@ try {
}
}
console.log(`PASS outcome note: ${RUNS.length} runs; screenshots in ${evidence}`);

// /compact, then /clear: the server lists the /compact send until herdr names the new session, which can be
// after the chat has read the new, empty conversation. The chat held /compact's outcome when it saw the move,
// or a poll lists it only after the move.
const at = (seconds: number) => new Date(Date.now() - 50_000 + seconds * 1000).toISOString();
const compact: CommandOutcome = { id: "compact-1", pane_id: "opencode-pane", text: "/compact", state: "result-observed", evidence: [{ kind: "transcript", at: at(1) }], created_at: at(0), updated_at: at(1) };
const clear: CommandOutcome = { ...compact, id: "clear-1", text: "/clear", state: "accepted", evidence: [], created_at: at(5), updated_at: at(5) };
const chrome = browser, url = `http://127.0.0.1:${server.port}`;
for (const held of [true, false]) {
turns = ready; history = "before-clear"; outcomes = held ? [compact] : [];
const context = await chrome.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US" });
try {
const page = await context.newPage();
page.setDefaultTimeout(10_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(url);
const row = (text: string) => page.locator("[data-chat-row]", { hasText: text });
const before = page.getByText("Ready when you are.", { exact: true });
if (held) await row("/compact").waitFor();
else await before.waitFor();
turns = []; history = "after-clear";
if (held) outcomes = [compact, clear];
await before.waitFor({ state: "detached" });
outcomes = [compact, clear];
await row("/clear").waitFor();
const when = held ? "held when the chat saw the move" : "listed only after the move";
assert.equal(await row("/compact").count(), 0, `the cleared chat shows no /compact row (${when})`);
assert.deepEqual(errors, [], "page errors after /clear");
console.log(`PASS after /clear the new conversation shows no row of a send to the one it left (${when}), and /clear keeps its own`);
} finally {
await context.close();
}
}
} finally {
await browser?.close();
server?.stop();
Expand Down
13 changes: 13 additions & 0 deletions server/outcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ describe("evidence-only command outcomes", () => {
expect(outcome(send("/compact", "codex", [row("compact")]), { transcript: entry("compacted") }).state).toBe("accepted");
expect(outcome(send("/context", "codex"), { transcript: local("/context") }).state).toBe("accepted");
});
it("calls Claude's /compact done only once the compaction is recorded, not when its line is written at the start", () => {
const line = (entry: object, ms: number) => JSON.stringify({ ...entry, timestamp: new Date(at + ms).toISOString() });
// Claude Code 2.1: `/compact` as a user line when it starts; the boundary, the summary, its echo and output when it ends
const started = line({ type: "user", message: { role: "user", content: "/compact" } }, 10);
const ended = [started,
line({ type: "system", subtype: "compact_boundary", content: "Conversation compacted" }, 40_000),
line({ type: "user", isCompactSummary: true, message: { role: "user", content: "This session is being continued from a previous conversation." } }, 40_000),
line({ type: "user", message: { role: "user", content: "<command-name>/compact</command-name>\n<command-message>compact</command-message>\n<command-args></command-args>" } }, 10),
line({ type: "user", message: { role: "user", content: "<local-command-stdout>Compacted</local-command-stdout>" } }, 40_001)].join("\n");
const sent = send("/compact", "claude", [row("compact")]);
expect(outcome(sent, { now: at + 2_000, transcript: started }).state).toBe("accepted");
expect(outcome(sent, { now: at + 41_000, transcript: ended })).toMatchObject({ state: "result-observed", evidence: [{ kind: "transcript", at: new Date(at + 40_000).toISOString() }] });
});
});

it("serves bounded outcomes, strips observer previews, and records only a successful card commit", async () => {
Expand Down
5 changes: 5 additions & 0 deletions server/outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ function transcriptEvidence(sent: CommandSend, records: readonly CommandTranscri
const message = object(entry.message);
const evidence = { offset, at: time };
if (sent.agent === "claude") {
// Claude Code writes /compact as a user line when it starts; the fold it made, the boundary, is its result
if (/^\/compact(?:\s|$)/u.test(normalize(sent.text))) {
if (entry.type === "system" && entry.subtype === "compact_boundary" && !used.has(offset)) return evidence;
continue;
}
const local = entry.type === "system" && entry.subtype === "local_command";
if ((entry.type === "user" && !entry.isMeta) || local) {
const content = local ? entry.content : message.content;
Expand Down
25 changes: 22 additions & 3 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import { LINE_IMAGES_SHOWN, LINE_IMAGES_SHOWN_TOUCH, lineImages, linePictures, p
import { shouldFoldUserMessage } from "../lib/userMessageFold.ts";
import { rewindOffer } from "../lib/rewindAction.ts";
import { invalidatePaneCommands } from "../lib/paneCommandCache.ts";
import { noteCatalogChanges, outcomeChip, outcomeCommand, outcomeLabel, outcomeNote, outcomeResultText, outcomeRows, type OutcomeState } from "../lib/outcomeChip.ts";
import { afterMove, noteCatalogChanges, outcomeChip, outcomeCommand, outcomeLabel, outcomeNote, outcomeResultText, outcomeRows, type OutcomeState } from "../lib/outcomeChip.ts";
import { typedTerminalCommand } from "../lib/compose.ts";
import { usePaneCommands } from "../lib/usePaneCommands.ts";
import { commandScreenClosed, sentOutcome, type TerminalPeek } from "../lib/terminalPeek.ts";
Expand Down Expand Up @@ -1285,6 +1285,11 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish
const [restored, setRestored] = useState<string | null>(null);
// the pane's tracked command sends, read with the prompt (lib/outcomeChip.ts draws them)
const [outcomes, setOutcomes] = useState<CommandOutcome[]>([]);
const outcomesNow = useRef(outcomes);
outcomesNow.current = outcomes;
/** a move to another history: the outcomes held when the chat saw it, and the first list after it (lib/outcomeChip.ts afterMove) */
const [historyMove, setHistoryMove] = useState<{ history: string | undefined; held: CommandOutcome[] } | null>(null);
const firstAfterMove = useRef<CommandOutcome[] | undefined>(undefined);
const onOutcomesRef = useRef(onOutcomes);
onOutcomesRef.current = onOutcomes;
/** the outcomes whose catalog change this chat has already answered with a fresh command read */
Expand Down Expand Up @@ -1558,7 +1563,16 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish
const kept = readChatPosition(storageId);
if (kept !== undefined && kept.historyId === (history.current ?? null)) rememberChatPosition(storageId, { ...kept, historyId: conversation.history_id ?? null });
}
else { if (history.current !== undefined) navigated(); dropOlder(); }
else {
if (history.current !== undefined) {
navigated();
// the sends to the history left behind are no receipts of this one: the last prompt poll holds them,
// and the server lists them until herdr names the new session, which can be a poll after this read
firstAfterMove.current = undefined;
setHistoryMove({ history: conversation.history_id, held: outcomesNow.current });
}
dropOlder();
}
generation = olderGeneration.current;
history.current = conversation.history_id; setHistoryId(conversation.history_id);
if (!continued) { pagingAway.current = false; stickToBottom.current = true; lastRowOffset.current = null; lastScrollHeight.current = 0; setNewMessages(false); setAway(false); }
Expand Down Expand Up @@ -2157,7 +2171,12 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish
const rowMemories = useMemo(() => new Map<string, ChatRowMemory>(), [timelineScope]);
// the turns with the commands sent from here: a chip on the turn that records one, a provisional
// sent row for one no turn records (yet), placed by send time (lib/outcomeChip.ts)
const withOutcomes = useMemo(() => outcomeRows(shownTurns, outcomes, { restored: hiddenSend, beginningLoaded: olderCursor === null }), [shownTurns, outcomes, hiddenSend, olderCursor]);
const shownOutcomes = useMemo(() => {
if (historyMove === null || historyMove.history !== historyId) return outcomes;
if (outcomes !== historyMove.held) firstAfterMove.current ??= outcomes;
return afterMove(outcomes, historyMove.held, firstAfterMove.current);
}, [outcomes, historyMove, historyId]);
const withOutcomes = useMemo(() => outcomeRows(shownTurns, shownOutcomes, { restored: hiddenSend, beginningLoaded: olderCursor === null }), [shownTurns, shownOutcomes, hiddenSend, olderCursor]);
const timelineRows = useMemo((): ChatRow[] => {
const rows = withOutcomes.map((row): ChatRow => {
if (row.kind === "sent") return { key: `outcome:${row.outcome.id}`, turn: null, outcome: row.outcome, beforeUser: false };
Expand Down
32 changes: 31 additions & 1 deletion src/lib/outcomeChip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";

import type { CommandOutcome, ConversationTurn } from "../../shared/protocol.ts";
import { translate } from "./i18n.ts";
import { noteCatalogChanges, outcomeChip, outcomeCommand, outcomeLabel, outcomeNote, outcomeResultText, outcomeRows } from "./outcomeChip.ts";
import { afterMove, noteCatalogChanges, outcomeChip, outcomeCommand, outcomeLabel, outcomeNote, outcomeResultText, outcomeRows } from "./outcomeChip.ts";

const en = (key: string, vars?: Record<string, string | number>): string => translate("en", key, vars);

Expand Down Expand Up @@ -80,6 +80,36 @@ describe("noteCatalogChanges", () => {
});
});

describe("afterMove", () => {
const sent = (id: string, text: string, second: number) => outcome({ id, text, created_at: `2026-10-20T10:00:${String(second).padStart(2, "0")}.000Z` });
const compact = sent("compact", "/compact", 1), clear = sent("clear", "/clear", 3), after = sent("after", "/context", 4);
const ids = (outcomes: readonly CommandOutcome[]) => outcomes.map((shown) => shown.id);

test("in the list held at the move, leaves out the sends before its newest /clear, /new or /resume, and all of them with none", () => {
const held = [after, clear, compact];
expect(ids(afterMove(held, held))).toEqual(["after", "clear"]);
const twice = [compact, sent("new", "/new", 2), clear];
expect(ids(afterMove(twice, twice))).toEqual(["clear"]);
// /clearly is another command
const none = [compact, sent("clearly", "/clearly", 2)];
expect(afterMove(none, none)).toEqual([]);
});

test("in a later list, leaves out the sends before the move also when that list brings them first", () => {
const first = [compact, clear];
expect(ids(afterMove(first, [], first))).toEqual(["clear"]);
// a /resume sent after the move did not move the chat: the sends before it stay
expect(ids(afterMove([clear, after, sent("resume", "/resume", 6)], [], first))).toEqual(["clear", "after", "resume"]);
});

test("with no move listed (a /clear typed in the terminal), keeps a later list whole: a send to the new conversation shows", () => {
const held = [after];
expect(afterMove(held, held)).toEqual([]);
const later = [{ ...after, state: "result-observed" as const }];
expect(afterMove(later, held, later)).toEqual(later);
});
});

describe("outcomeRows", () => {
const earlier = [user("hello", "2026-10-20T09:00:00.000Z"), assistant("hi", "2026-10-20T09:00:05.000Z")];

Expand Down
19 changes: 19 additions & 0 deletions src/lib/outcomeChip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,25 @@ export function outcomeResultText(outcome: CommandOutcome, dismissed: boolean):
return outcome.result_text || null;
}

/** A command that moves the pane to another conversation (server/prompt.ts `sessionChange`). */
const SESSION_CHANGE = /^\/(?:clear|new|reset|resume)(?:\s|$)/iu;

/**
* The outcomes of a conversation the chat moved to, without the sends to the one it left. `held` is the
* list the chat held when it saw the move, `first` the first other list after it (the caller keeps it).
* The newest /clear, /new, /reset or /resume in the held list, or in `first` once the list changed, made
* the move: a later one did not move the chat. Every send before it, by the server's send times, is the
* old conversation's, also one a later poll lists first. With no such command (a /clear typed in the
* terminal), the held list is all the old conversation's, and a later list stands: it tells no old send
* from a new one.
*/
export function afterMove(outcomes: readonly CommandOutcome[], held: readonly CommandOutcome[], first = outcomes): readonly CommandOutcome[] {
const moves = (outcomes === held ? held : first).filter((outcome) => SESSION_CHANGE.test(outcome.text.trim()));
if (moves.length === 0) return outcomes === held ? [] : outcomes;
const move = Math.max(...moves.map((outcome) => Date.parse(outcome.created_at)));
return outcomes.filter((outcome) => Date.parse(outcome.created_at) >= move);
}

/** Calls `invalidate` for each outcome that changed the agent's command list, once: `noted` keeps the ids already acted on. */
export function noteCatalogChanges(outcomes: readonly CommandOutcome[], noted: Set<string>, invalidate: () => void): void {
for (const outcome of outcomes) {
Expand Down
Loading