From 5e88e91f953f2abf5e8244748ff9e678f964ed80 Mon Sep 17 00:00:00 2001 From: Rose <18020331@vnu.edu.vn> Date: Sat, 27 Jun 2026 21:28:02 +0700 Subject: [PATCH] fix: wire Claude /context and /compact through to the Agent SDK Strip the wrapped prompt so the SDK recognises the root command, and surface the compact lifecycle as system notices. Supersedes the /compact suppression from #898. --- .changeset/fix-claude-context-compact.md | 5 ++ sidecar/src/claude/compact-filter.test.ts | 84 +++++++++++++++++++ sidecar/src/claude/compact-filter.ts | 35 ++++++++ sidecar/src/claude/session-manager.test.ts | 57 +++++++++++++ sidecar/src/claude/session-manager.ts | 43 +++++++++- src-tauri/src/agents/catalog.rs | 4 +- src-tauri/src/pipeline/accumulator/mod.rs | 65 ++++++++++++-- src-tauri/src/pipeline/adapter/labels.rs | 9 ++ src-tauri/src/pipeline/adapter/mod.rs | 6 +- src-tauri/src/pipeline/event_filter.rs | 21 ++++- ...replay@claude__compact-boundary.jsonl.snap | 9 +- src/features/composer/container.test.tsx | 12 ++- src/features/composer/container.tsx | 11 +-- .../system-message.test.tsx | 51 +++++++++++ .../message-components/system-message.tsx | 41 ++++++--- 15 files changed, 404 insertions(+), 49 deletions(-) create mode 100644 .changeset/fix-claude-context-compact.md create mode 100644 sidecar/src/claude/compact-filter.test.ts create mode 100644 sidecar/src/claude/compact-filter.ts create mode 100644 sidecar/src/claude/session-manager.test.ts create mode 100644 src/features/panel/message-components/system-message.test.tsx diff --git a/.changeset/fix-claude-context-compact.md b/.changeset/fix-claude-context-compact.md new file mode 100644 index 000000000..012193d0d --- /dev/null +++ b/.changeset/fix-claude-context-compact.md @@ -0,0 +1,5 @@ +--- +"helmor": patch +--- + +Wire Claude `/context` and `/compact` slash commands through to the Agent SDK — the wrapped prompt previously hid them from the SDK's root-command detection — and surface the compact lifecycle as system notices with a spinner while compacting and a check when done. diff --git a/sidecar/src/claude/compact-filter.test.ts b/sidecar/src/claude/compact-filter.test.ts new file mode 100644 index 000000000..64ae1f183 --- /dev/null +++ b/sidecar/src/claude/compact-filter.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { shouldSuppressCompactContextUsageMessage } from "./compact-filter.js"; + +/** Build a synthetic `## Context Usage` assistant message — the redundant + * table the Claude Agent SDK emits after `/compact`. */ +function syntheticContextUsageMessage( + text = "## Context Usage\ntokens low", +): SDKMessage { + return { + type: "assistant", + message: { + model: "", + content: [{ type: "text", text }], + }, + } as unknown as SDKMessage; +} + +function realAssistantMessage(text: string): SDKMessage { + return { + type: "assistant", + message: { + model: "claude-sonnet-4", + content: [{ type: "text", text }], + }, + } as unknown as SDKMessage; +} + +describe("shouldSuppressCompactContextUsageMessage", () => { + test("drops synthetic ## Context Usage message for /compact", () => { + expect( + shouldSuppressCompactContextUsageMessage( + syntheticContextUsageMessage(), + "/compact", + ), + ).toBe(true); + }); + + test("keeps synthetic ## Context Usage message for /context (useful output)", () => { + expect( + shouldSuppressCompactContextUsageMessage( + syntheticContextUsageMessage(), + "/context all", + ), + ).toBe(false); + }); + + test("keeps synthetic ## Context Usage message when no root slash command", () => { + expect( + shouldSuppressCompactContextUsageMessage( + syntheticContextUsageMessage(), + null, + ), + ).toBe(false); + }); + + test("keeps real (non-synthetic) assistant message even for /compact", () => { + expect( + shouldSuppressCompactContextUsageMessage( + realAssistantMessage("## Context Usage\ntokens low"), + "/compact", + ), + ).toBe(false); + }); + + test("keeps synthetic assistant message whose text is not ## Context Usage for /compact", () => { + expect( + shouldSuppressCompactContextUsageMessage( + syntheticContextUsageMessage("Unrelated summary"), + "/compact", + ), + ).toBe(false); + }); + + test("keeps non-assistant messages for /compact", () => { + const systemMessage = { + type: "system", + subtype: "compact_boundary", + } as unknown as SDKMessage; + expect( + shouldSuppressCompactContextUsageMessage(systemMessage, "/compact"), + ).toBe(false); + }); +}); diff --git a/sidecar/src/claude/compact-filter.ts b/sidecar/src/claude/compact-filter.ts new file mode 100644 index 000000000..37fc95b02 --- /dev/null +++ b/sidecar/src/claude/compact-filter.ts @@ -0,0 +1,35 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +/// Detect the synthetic `## Context Usage` assistant message the SDK emits +/// after `/compact` — redundant once `compact_boundary` confirms the compact. +function isCompactContextUsageMessage(message: SDKMessage): boolean { + if (message.type !== "assistant") { + return false; + } + const payload = ( + message as { message?: { model?: string; content?: unknown } } + ).message; + if (payload?.model !== "") { + return false; + } + const content = payload.content; + if (!Array.isArray(content) || content.length === 0) { + return false; + } + const first = content[0] as { type?: string; text?: string }; + return ( + first.type === "text" && + typeof first.text === "string" && + first.text.startsWith("## Context Usage") + ); +} + +/// Only drop the table for `/compact` — `/context` produces it on purpose. +export function shouldSuppressCompactContextUsageMessage( + message: SDKMessage, + rootSlashCommand: string | null, +): boolean { + return ( + rootSlashCommand === "/compact" && isCompactContextUsageMessage(message) + ); +} diff --git a/sidecar/src/claude/session-manager.test.ts b/sidecar/src/claude/session-manager.test.ts new file mode 100644 index 000000000..bde80c720 --- /dev/null +++ b/sidecar/src/claude/session-manager.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeRootSlashCommand } from "./session-manager.js"; + +describe("normalizeRootSlashCommand", () => { + test("bare /compact -> /compact", () => { + expect(normalizeRootSlashCommand("/compact")).toBe("/compact"); + }); + + test("/compact with args -> /compact (args dropped)", () => { + expect(normalizeRootSlashCommand("/compact focus on auth")).toBe( + "/compact", + ); + }); + + test("bare /context -> /context all", () => { + expect(normalizeRootSlashCommand("/context")).toBe("/context all"); + }); + + test("/context with args -> /context all", () => { + expect(normalizeRootSlashCommand("/context show usage")).toBe( + "/context all", + ); + }); + + test("wrapped /compact (helmor preamble + User request marker) -> /compact", () => { + const wrapped = `\nYou are running inside Helmor...\n\n\nUser request:\n/compact`; + expect(normalizeRootSlashCommand(wrapped)).toBe("/compact"); + }); + + test("wrapped /context -> /context all", () => { + const wrapped = `\nframing\n\n\nUser request:\n/context`; + expect(normalizeRootSlashCommand(wrapped)).toBe("/context all"); + }); + + test("prompt with linked-dirs block AND helmor preamble -> command still extracted", () => { + const wrapped = `\nframing\n\n[Linked directories — you have read/write access:\n- /other]\n\nUser request:\n/compact`; + expect(normalizeRootSlashCommand(wrapped)).toBe("/compact"); + }); + + test("/goal is not a recognized root command -> null", () => { + expect(normalizeRootSlashCommand("/goal fix all bugs")).toBe(null); + }); + + test("normal prompt -> null", () => { + expect(normalizeRootSlashCommand("fix the bug in auth.ts")).toBe(null); + }); + + test("wrapped normal prompt -> null", () => { + const wrapped = `\nframing\n\n\nUser request:\nfix the bug in auth.ts`; + expect(normalizeRootSlashCommand(wrapped)).toBe(null); + }); + + test("command-like text not at the end (mid-prompt) -> null", () => { + const wrapped = `\nframing\n\n\nUser request:\nPlease run /compact for me`; + expect(normalizeRootSlashCommand(wrapped)).toBe(null); + }); +}); diff --git a/sidecar/src/claude/session-manager.ts b/sidecar/src/claude/session-manager.ts index e7ecedd42..2bd899b87 100644 --- a/sidecar/src/claude/session-manager.ts +++ b/sidecar/src/claude/session-manager.ts @@ -47,6 +47,7 @@ import { parseTitleAndBranchWithDiagnostics, TITLE_GENERATION_TIMEOUT_MS, } from "../title.js"; +import { shouldSuppressCompactContextUsageMessage } from "./compact-filter.js"; import { loadProjectMcpServers } from "./project-mcp.js"; /** @@ -67,6 +68,29 @@ const SLASH_COMMANDS_TIMEOUT_MS = 20_000; */ const CONTEXT_USAGE_TIMEOUT_MS = 30_000; +/** + * Helmor wraps every user prompt as `{helmor preamble}\n\nUser request:\n{prompt}` + * (and may also prepend a `[Linked directories …]` block). The Claude Agent SDK + * only recognises a root slash command when the prompt it receives starts with + * `/`, so a wrapped `/compact` or `/context` is treated as literal text + * and silently does nothing. Strip the wrapping and return the bare command the + * SDK needs; return `null` for any non-command prompt so the normal path runs. + */ +export function normalizeRootSlashCommand(prompt: string): string | null { + const marker = "\n\nUser request:\n"; + const markerIndex = prompt.lastIndexOf(marker); + const text = ( + markerIndex === -1 ? prompt : prompt.slice(markerIndex + marker.length) + ).trim(); + if (text === "/context" || text.startsWith("/context ")) { + return "/context all"; + } + if (text === "/compact" || text.startsWith("/compact ")) { + return "/compact"; + } + return null; +} + /** * Resolve the Claude Code native binary for `pathToClaudeCodeExecutable`. * Prefers `HELMOR_CLAUDE_CODE_BIN_PATH` (release), then the platform @@ -408,10 +432,14 @@ export class ClaudeSessionManager implements SessionManager { directories: additionalDirectories, cwd: cwd ?? "(none)", }); - const promptWithContext = prependLinkedDirectoriesContext( - prompt, - additionalDirectories, - ); + // Root slash commands (/compact, /context) must reach the SDK bare — any + // prepended context breaks the SDK's root-command detection. Strip the + // Helmor wrapping; for normal prompts keep the linked-dirs hint. + const rootSlashCommand = normalizeRootSlashCommand(prompt); + const promptForSdk = rootSlashCommand ?? prompt; + const promptWithContext = rootSlashCommand + ? promptForSdk + : prependLinkedDirectoriesContext(promptForSdk, additionalDirectories); const { text, imagePaths } = parseImageRefs(promptWithContext, images); const promptSource = createPushable(); @@ -698,6 +726,13 @@ export class ClaudeSessionManager implements SessionManager { // `end` here would violate the "exactly one terminal event" contract. if (this.turns.isAbortRequested(sessionId)) return; logger.sdkEvent(requestId, message); + // /compact: drop the redundant synthetic `## Context Usage` reply — + // `compact_boundary` already confirms the compact. + if ( + shouldSuppressCompactContextUsageMessage(message, rootSlashCommand) + ) { + continue; + } if (message.type === "rate_limit_event") { lastRateLimitInfo = ( message as { rate_limit_info?: RateLimitOverageInfo } diff --git a/src-tauri/src/agents/catalog.rs b/src-tauri/src/agents/catalog.rs index 8ce06a5ac..1a72873ad 100644 --- a/src-tauri/src/agents/catalog.rs +++ b/src-tauri/src/agents/catalog.rs @@ -724,7 +724,7 @@ fn custom_provider_options( provider_key: Some(model.provider_key), effort_levels: claude_effort_levels(), supports_fast_mode: false, - supports_context_usage: false, + supports_context_usage: true, }) .collect() } @@ -1072,7 +1072,7 @@ mod tests { sections[0].options[6].effort_levels, vec!["low", "medium", "high", "xhigh", "max"] ); - assert!(!sections[0].options[6].supports_context_usage); + assert!(sections[0].options[6].supports_context_usage); assert_eq!(sections[1].id, "codex"); } diff --git a/src-tauri/src/pipeline/accumulator/mod.rs b/src-tauri/src/pipeline/accumulator/mod.rs index 5f231cf04..bdd260588 100644 --- a/src-tauri/src/pipeline/accumulator/mod.rs +++ b/src-tauri/src/pipeline/accumulator/mod.rs @@ -33,6 +33,13 @@ use super::types::{ }; use streaming::StreamingBlock; +fn is_claude_compact_lifecycle_event(value: &Value) -> bool { + let subtype = value.get("subtype").and_then(Value::as_str); + matches!(subtype, Some("compact_boundary")) + || (subtype == Some("status") + && value.get("status").and_then(Value::as_str) == Some("compacting")) +} + #[cfg(test)] mod tests; @@ -144,6 +151,7 @@ pub struct StreamAccumulator { /// parent Task/Agent tool call instead of flashing as a top-level bubble. pub(super) cur_streaming_parent_id: Option, local_bash_task_refs: HashSet, + compact_lifecycle_id: Option, // ── Codex state ────────────────────────────────────────────────── /// Per-item delta accumulation for Codex App Server streaming. codex_items: HashMap, @@ -353,6 +361,7 @@ impl StreamAccumulator { cur_asst_block_count: 0, cur_streaming_parent_id: None, local_bash_task_refs: HashSet::new(), + compact_lifecycle_id: None, codex_items: codex::new_item_states(), codex_partial_idx: None, codex_turn_started_at: None, @@ -1245,13 +1254,15 @@ impl StreamAccumulator { } fn handle_claude_system(&mut self, raw_line: &str, value: &Value) { - // Subtypes listed in `event_filter::SUPPRESSED_SYSTEM_SUBTYPES` - // never enter `collected[]` — they don't cross IPC, don't render, - // don't waste downstream work. Edit that file to toggle. - if let Some(subtype) = value.get("subtype").and_then(Value::as_str) { - if crate::pipeline::event_filter::is_suppressed_system_subtype(subtype) { - return; - } + // Suppressed system events never enter `collected[]` — they don't cross + // IPC, don't render, don't waste downstream work. Edit + // `event_filter` to toggle. + if crate::pipeline::event_filter::is_suppressed_system_event(value) { + return; + } + if is_claude_compact_lifecycle_event(value) { + self.collect_compact_lifecycle(raw_line, value); + return; } // `local_bash` task_* events duplicate the accompanying Bash tool. // Claude omits `task_type` on the later notification, so remember @@ -1351,6 +1362,46 @@ impl StreamAccumulator { self.cur_asst_block_count = 0; } + fn collect_compact_lifecycle(&mut self, raw: &str, parsed: &Value) { + let subtype = parsed.get("subtype").and_then(Value::as_str); + let id = match subtype { + Some("status") => { + let id = parsed + .get("uuid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + self.compact_lifecycle_id = Some(id.clone()); + id + } + Some("compact_boundary") => self + .compact_lifecycle_id + .take() + .or_else(|| { + parsed + .get("uuid") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + _ => uuid::Uuid::new_v4().to_string(), + }; + + self.collect_or_replace(raw, parsed, MessageRole::System, Some(id.clone())); + + if subtype == Some("compact_boundary") { + if let Some(pos) = self.turns.iter().rposition(|turn| turn.id == id) { + self.turns[pos].content_json = raw.to_string(); + } else { + self.turns.push(CollectedTurn { + id, + role: MessageRole::System, + content_json: raw.to_string(), + }); + } + } + } + pub(super) fn collect_message( &mut self, raw: &str, diff --git a/src-tauri/src/pipeline/adapter/labels.rs b/src-tauri/src/pipeline/adapter/labels.rs index a77ca18dc..184bf759d 100644 --- a/src-tauri/src/pipeline/adapter/labels.rs +++ b/src-tauri/src/pipeline/adapter/labels.rs @@ -217,6 +217,15 @@ pub(super) fn build_system_notice(parsed: Option<&Value>, msg_id: &str) -> Optio }) } "compact_boundary" => Some(build_compact_boundary_notice(parsed, msg_id)), + "status" if parsed.get("status").and_then(Value::as_str) == Some("compacting") => { + Some(MessagePart::SystemNotice { + id: notice_part_id(msg_id), + severity: NoticeSeverity::Info, + label: "Compacting context".to_string(), + body: None, + }) + } + "status" => None, "codex_compacting" => Some(MessagePart::SystemNotice { id: notice_part_id(msg_id), severity: NoticeSeverity::Info, diff --git a/src-tauri/src/pipeline/adapter/mod.rs b/src-tauri/src/pipeline/adapter/mod.rs index 33876204e..71817c268 100644 --- a/src-tauri/src/pipeline/adapter/mod.rs +++ b/src-tauri/src/pipeline/adapter/mod.rs @@ -697,10 +697,8 @@ fn convert_system_msg( // Apply the same noise filter live ingest uses, so old persisted // rows from earlier code versions render with the new rules. Edit // `pipeline::event_filter` to toggle. - if let Some(s) = sub { - if super::event_filter::is_suppressed_system_subtype(s) { - return; - } + if parsed.is_some_and(super::event_filter::is_suppressed_system_event) { + return; } if let Some(value) = parsed { if super::event_filter::is_suppressed_local_bash_task(value) { diff --git a/src-tauri/src/pipeline/event_filter.rs b/src-tauri/src/pipeline/event_filter.rs index 7772c8cd2..68ea77e8c 100644 --- a/src-tauri/src/pipeline/event_filter.rs +++ b/src-tauri/src/pipeline/event_filter.rs @@ -3,9 +3,9 @@ //! Single source of truth — three call sites consult this module: //! - `accumulator::push_event` reads `SUPPRESSED_EVENT_TYPES` and drops //! matching top-level events before any handler runs (NoOp). -//! - `accumulator::handle_claude_system` reads `SUPPRESSED_SYSTEM_SUBTYPES` +//! - `accumulator::handle_claude_system` reads `is_suppressed_system_event` //! + local-bash task helpers on live ingest. -//! - `adapter::convert_system_msg` reads the same pair on historical +//! - `adapter::convert_system_msg` reads the same helper on historical //! reload, so old persisted noise rows from earlier code versions //! render with the same rules as new turns. //! @@ -46,8 +46,8 @@ pub(crate) const SUPPRESSED_SYSTEM_SUBTYPES: &[&str] = &[ "session_state_changed", "files_persisted", "elicitation_complete", - // Status pings (`{status: 'compacting' | null}`) — comment out to - // surface the compacting indicator. + // Status pings. `status: "compacting"` is allowed by + // `is_suppressed_system_event`; null/other status values stay silent. "status", // Per-frame thinking-token estimate (sdk 0.3.x) — a progress signal, not // a message. We render no pill for it, so drop it. @@ -74,6 +74,19 @@ pub(crate) fn is_suppressed_system_subtype(subtype: &str) -> bool { SUPPRESSED_SYSTEM_SUBTYPES.contains(&subtype) } +/// Same as `is_suppressed_system_subtype` but takes the full event value, so +/// it can let `status: "compacting"` through (the compacting indicator) while +/// still suppressing null/other status pings. +pub(crate) fn is_suppressed_system_event(value: &Value) -> bool { + let Some(subtype) = value.get("subtype").and_then(Value::as_str) else { + return false; + }; + if subtype == "status" && value.get("status").and_then(Value::as_str) == Some("compacting") { + return false; + } + is_suppressed_system_subtype(subtype) +} + /// `task_type: "local_bash"` lifecycle events are Claude wrapping a /// single Bash command with its own started/progress/completed notices. /// The `tool_use_id` on them points at the Bash tool call — which our diff --git a/src-tauri/tests/snapshots/pipeline_streams__stream_replay@claude__compact-boundary.jsonl.snap b/src-tauri/tests/snapshots/pipeline_streams__stream_replay@claude__compact-boundary.jsonl.snap index 8729469cd..ddf1bf427 100644 --- a/src-tauri/tests/snapshots/pipeline_streams__stream_replay@claude__compact-boundary.jsonl.snap +++ b/src-tauri/tests/snapshots/pipeline_streams__stream_replay@claude__compact-boundary.jsonl.snap @@ -49,9 +49,11 @@ final_state: - system total_parts: 4 persisted_turns: - turn_count: 2 + turn_count: 3 total_blocks: 2 turns: + - role: system + block_types: [] - role: assistant block_types: - text @@ -59,8 +61,11 @@ persisted_turns: block_types: - text historical_render: - message_count: 1 + message_count: 2 messages: + - role: system + part_types: + - system-notice - role: assistant part_types: - text diff --git a/src/features/composer/container.test.tsx b/src/features/composer/container.test.tsx index 3101466a5..2c9327615 100644 --- a/src/features/composer/container.test.tsx +++ b/src/features/composer/container.test.tsx @@ -1248,10 +1248,10 @@ describe("WorkspaceComposerContainer", () => { }); }); - it("suppresses the SDK-reported /compact command for Claude sessions", async () => { - // claude-code lists /compact among its slash commands, but the Agent - // SDK has no programmatic compaction path, so Helmor hides it for - // Claude (Codex/OpenCode keep their built-in /compact). + it("shows the SDK-reported /compact command for Claude sessions", async () => { + // /compact is now wired through to the Agent SDK as a bare root + // command (see normalizeRootSlashCommand), so show it rather than + // hiding it as a dead command. apiMockState.listSlashCommands.mockResolvedValue({ commands: [ { @@ -1275,12 +1275,10 @@ describe("WorkspaceComposerContainer", () => { "add-dir", "goal", "workflows", + "compact", "review", ]); }); - expect( - composerMockState.lastSlashCommands.some((c) => c.name === "compact"), - ).toBe(false); }); it("suppresses the SDK-reported /clear command (no-op for every provider)", async () => { diff --git a/src/features/composer/container.tsx b/src/features/composer/container.tsx index a68b568cf..3d1c25926 100644 --- a/src/features/composer/container.tsx +++ b/src/features/composer/container.tsx @@ -144,15 +144,12 @@ const BUILTIN_CLIENT_COMMANDS: readonly SlashCommandEntry[] = [ CLAUDE_WORKFLOWS_COMMAND, ]; -// SDK-reported commands to hide per provider. claude-code lists /compact among -// its slash commands, but the Agent SDK exposes no programmatic compaction path -// (unlike Codex's thread/compact/start and OpenCode's session.summarize), so -// sending it is a no-op — suppress it instead of showing a dead command. +// SDK-reported commands to hide per provider. /compact is now wired through +// to the Claude Agent SDK as a bare root command (see normalizeRootSlashCommand +// in the sidecar), so it is no longer suppressed for Claude. const SUPPRESSED_AGENT_COMMANDS: Partial< Record> -> = { - claude: new Set(["compact"]), -}; +> = {}; // SDK-reported commands to hide for every provider. /clear is a REPL-only // command: sent through the SDK it's a literal no-op (the next turn still diff --git a/src/features/panel/message-components/system-message.test.tsx b/src/features/panel/message-components/system-message.test.tsx new file mode 100644 index 000000000..8b34f16cc --- /dev/null +++ b/src/features/panel/message-components/system-message.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SystemNoticePart } from "@/lib/api"; +import { + COMPACTED_LABEL, + COMPACTING_LABEL, + SystemNotice, +} from "./system-message"; + +afterEach(cleanup); + +function notice(overrides: Partial = {}): SystemNoticePart { + return { + type: "system-notice", + id: "sn-1", + severity: "info", + label: "Notice", + ...overrides, + }; +} + +describe("SystemNotice — compact lifecycle", () => { + it("renders the Compacting context label with a spinning loader icon", () => { + const { container } = render( + , + ); + expect(screen.getByText(COMPACTING_LABEL)).toBeInTheDocument(); + expect(container.querySelector(".lucide-loader-circle")).not.toBeNull(); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + }); + + it("renders the Context compacted label with a check icon (no spin)", () => { + const { container } = render( + , + ); + expect(screen.getByText(COMPACTED_LABEL)).toBeInTheDocument(); + expect(container.querySelector(".lucide-check")).not.toBeNull(); + expect(container.querySelector(".animate-spin")).toBeNull(); + }); + + it("renders a plain info notice with the info icon", () => { + const { container } = render( + , + ); + expect(container.querySelector(".lucide-info")).not.toBeNull(); + expect(container.querySelector(".lucide-loader-circle")).toBeNull(); + expect(container.querySelector(".lucide-check")).toBeNull(); + }); +}); diff --git a/src/features/panel/message-components/system-message.tsx b/src/features/panel/message-components/system-message.tsx index acf85877e..8abe61465 100644 --- a/src/features/panel/message-components/system-message.tsx +++ b/src/features/panel/message-components/system-message.tsx @@ -2,8 +2,10 @@ import { formatDistanceToNow } from "date-fns"; import { AlertCircle, AlertTriangle, + Check, Goal, Info, + Loader2, MessageSquareText, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -29,6 +31,12 @@ import { // --- sub-components --- +// Compact-lifecycle notice labels from `pipeline/adapter/labels.rs`. +// Same pair for Claude (`status:compacting` / `compact_boundary`) and +// Codex (`codex_compacting` / `codex_compacted`) — one icon mapping covers both. +export const COMPACTING_LABEL = "Compacting context"; +export const COMPACTED_LABEL = "Context compacted"; + export function SystemNotice({ part, wrap = false, @@ -36,18 +44,27 @@ export function SystemNotice({ part: SystemNoticePart; wrap?: boolean; }) { - const Icon = - part.severity === "error" - ? AlertCircle - : part.severity === "warning" - ? AlertTriangle - : Info; - const iconClass = - part.severity === "error" - ? "text-destructive" - : part.severity === "warning" - ? "text-chart-5" - : "text-chart-3"; + // Compact lifecycle: spinner while compacting, check when done. + const isCompacting = part.label === COMPACTING_LABEL; + const isCompacted = part.label === COMPACTED_LABEL; + const Icon = isCompacting + ? Loader2 + : isCompacted + ? Check + : part.severity === "error" + ? AlertCircle + : part.severity === "warning" + ? AlertTriangle + : Info; + const iconClass = isCompacting + ? "animate-spin text-muted-foreground/70" + : isCompacted + ? "text-chart-2" + : part.severity === "error" + ? "text-destructive" + : part.severity === "warning" + ? "text-chart-5" + : "text-chart-3"; return (