From a68b4c8a66c965bcde8a34797232303103174689 Mon Sep 17 00:00:00 2001 From: armand_mounsi Date: Mon, 28 Sep 2026 10:07:00 +0200 Subject: [PATCH 01/24] =?UTF-8?q?fix(timing):=20time=20a=20prompt=20as=20o?= =?UTF-8?q?ne=20run,=20Enter=20=E2=86=92=20last=20background=20task=20(CRM?= =?UTF-8?q?=20d1ce0aa6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt that launches background work produces several `result`s: the main answer, then one follow-up turn the CLI starts on its own per background task that reports back (verified live, claude 2.1.283). Each follow-up restamped the turn clock — the live counter restarted — and printed its own short footer. - agent/runClock.ts: a RUN opens on the user's send, owns the background tasks first seen while it is the latest run, absorbs the follow-up turns they trigger, and ends when nothing of it runs any more. - Thread: one timing line per run, under its latest result — main-answer time, then a live green "Background task running · 1m 12s" counted from the Enter, then "46s total" once the work is done. The working indicator, the sidebar row and the "Conversation ended" band read the run clock too. - result.duration_api_ms is a cumulative per-session counter (restored across --resume): the assembler now reports each turn's own delta, unknown on the first turn of a resumed process. - fmtDuration no longer prints "1m 60s". Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/supervisor/assembler.rs | 97 ++++- src-tauri/src/supervisor/model.rs | 5 +- src-tauri/src/supervisor/session.rs | 8 +- src/agent/rowTiming.test.ts | 29 ++ src/agent/rowTiming.ts | 35 +- src/agent/runClock.test.ts | 259 ++++++++++++++ src/agent/runClock.ts | 334 ++++++++++++++++++ src/agent/subagentMeta.test.ts | 15 + src/agent/subagentMeta.ts | 7 +- .../conversation/ComposerStatusBand.tsx | 16 +- .../conversation/ConductorSidebar.tsx | 2 + .../conversation/ConductorThread.module.css | 34 ++ src/features/conversation/ConductorThread.tsx | 95 ++++- src/features/settings/SettingsPanel.tsx | 7 +- src/ipc/useGlobalSessionEvents.ts | 2 + src/store/conversationStore.ts | 57 ++- src/store/display.ts | 17 +- src/store/runClockStore.test.ts | 115 ++++++ src/store/types.ts | 14 +- 19 files changed, 1099 insertions(+), 49 deletions(-) create mode 100644 src/agent/runClock.test.ts create mode 100644 src/agent/runClock.ts create mode 100644 src/store/runClockStore.test.ts diff --git a/src-tauri/src/supervisor/assembler.rs b/src-tauri/src/supervisor/assembler.rs index 90c7aadc..98a6ad60 100644 --- a/src-tauri/src/supervisor/assembler.rs +++ b/src-tauri/src/supervisor/assembler.rs @@ -76,6 +76,14 @@ pub struct Assembler { /// end-of-turn (`ingest_result`) so it can never swallow a real user turn (those only /// arrive AFTER a `result`, never mid-turn). skill_invocation_pending: bool, + /// The CLI's CUMULATIVE `result.duration_api_ms` as of the previous `result` — the + /// baseline a turn's own model time is measured from. The wire value is a running + /// per-SESSION total (verified live, claude 2.1.283: 2.1s → 6.0s → 7.1s over three + /// plain prompts) that is even restored across `--resume` (7.1s → 7.9s after a + /// re-spawn), NOT a per-turn figure. `Some(0)` for a brand-new session (the counter + /// starts at zero — see [`Assembler::mark_fresh_session`]); `None` while unknown (a + /// resumed or re-attached process, until its first `result` sets it). + api_ms_baseline: Option, } /// The last-announced friendly labels for the three controls (see [`Assembler`]). @@ -108,6 +116,27 @@ impl Assembler { Self::default() } + /// This process started a brand-new session (no `--resume`, no re-attach), so the + /// CLI's cumulative model-time counter starts at zero and the FIRST turn's model time + /// is known too. Left unset otherwise: a resumed session's restored total cannot be + /// known before its first `result`, whose model time is then reported as unknown. + pub fn mark_fresh_session(&mut self) { + self.api_ms_baseline = Some(0); + } + + /// The model time spent since the previous `result` — this turn's share of the CLI's + /// cumulative `duration_api_ms` (see [`Self::api_ms_baseline`]). `None` when the + /// baseline is unknown, or when the counter went BACKWARDS (a re-spawned process that + /// restored an older total): the delta would be meaningless, so the turn reports none + /// and the new total becomes the baseline. A `result` without the field leaves the + /// baseline untouched. + fn turn_api_ms(&mut self, cumulative: Option) -> Option { + let total = cumulative?; + let turn = self.api_ms_baseline.and_then(|base| total.checked_sub(base)); + self.api_ms_baseline = Some(total); + turn + } + /// Read-only view of the current session state. pub fn state(&self) -> &SessionStatePayload { &self.state @@ -1098,7 +1127,7 @@ impl Assembler { total_cost_usd: r.total_cost_usd, num_turns: r.num_turns, duration_ms: r.duration_ms, - duration_api_ms: r.duration_api_ms, + duration_api_ms: self.turn_api_ms(r.duration_api_ms), ttft_ms: r.ttft_ms, })); out.push(SessionEvent::State(self.state.clone())); @@ -1443,6 +1472,9 @@ mod tests { #[test] fn assembles_fixture_into_normalized_events() { let mut asm = Assembler::new(); + // The capture is a brand-new session's first turn: its cumulative model time IS + // this turn's. + asm.mark_fresh_session(); let mut streamed_text = String::new(); let mut saw_model = false; let mut saw_session_id = false; @@ -1840,6 +1872,69 @@ mod tests { assert_eq!(asm.state().context_window, Some(1_000_000)); } + /// A `result` line carrying only the timing fields under test. + fn timed_result(duration_ms: u64, cumulative_api_ms: u64) -> CliMessage { + serde_json::from_value(serde_json::json!({ + "type": "result", + "subtype": "success", + "is_error": false, + "result": "ok", + "session_id": "s", + "uuid": "u", + "duration_ms": duration_ms, + "duration_api_ms": cumulative_api_ms, + })) + .unwrap() + } + + /// The (duration_ms, duration_api_ms) of the TurnResult a `result` normalizes to. + fn turn_timing(asm: &mut Assembler, msg: &CliMessage) -> (Option, Option) { + asm.ingest(msg) + .into_iter() + .find_map(|ev| match ev { + SessionEvent::Item(ConversationItem::TurnResult { + duration_ms, + duration_api_ms, + .. + }) => Some((duration_ms, duration_api_ms)), + _ => None, + }) + .expect("a result normalizes to a TurnResult") + } + + /// `result.duration_api_ms` is a running per-session TOTAL on the wire (values from a + /// live probe of claude 2.1.283, three prompts in one process). Each turn must report + /// its OWN share, or every footer after the first shows the whole session's model time. + #[test] + fn turn_model_time_is_the_delta_of_the_cumulative_counter() { + let mut asm = Assembler::new(); + asm.mark_fresh_session(); + assert_eq!(turn_timing(&mut asm, &timed_result(1265, 2128)), (Some(1265), Some(2128))); + assert_eq!(turn_timing(&mut asm, &timed_result(3907, 6016)), (Some(3907), Some(3888))); + assert_eq!(turn_timing(&mut asm, &timed_result(1115, 7086)), (Some(1115), Some(1070))); + } + + /// A resumed process restores the session's earlier total, which we cannot know + /// before its first `result`: that turn's model time is unknown (never the whole + /// restored total), the next ones are exact again. + #[test] + fn turn_model_time_is_unknown_on_the_first_result_of_a_resumed_process() { + let mut asm = Assembler::new(); // not marked fresh: a --resume spawn + assert_eq!(turn_timing(&mut asm, &timed_result(863, 7894)), (Some(863), None)); + assert_eq!(turn_timing(&mut asm, &timed_result(900, 8800)), (Some(900), Some(906))); + } + + /// A counter that went BACKWARDS (a re-spawned process restored an older total) yields + /// no model time for that turn instead of an underflow, and re-bases on the new total. + #[test] + fn turn_model_time_survives_a_counter_that_went_backwards() { + let mut asm = Assembler::new(); + asm.mark_fresh_session(); + assert_eq!(turn_timing(&mut asm, &timed_result(1000, 5000)).1, Some(5000)); + assert_eq!(turn_timing(&mut asm, &timed_result(1000, 3000)).1, None); + assert_eq!(turn_timing(&mut asm, &timed_result(1000, 3500)).1, Some(500)); + } + const TASKS_CAPTURE: &str = include_str!("fixtures/capture_tasks.jsonl"); /// Feed the captured task lifecycle through the assembler and collect the final diff --git a/src-tauri/src/supervisor/model.rs b/src-tauri/src/supervisor/model.rs index 8a7f04af..0cb4406f 100644 --- a/src-tauri/src/supervisor/model.rs +++ b/src-tauri/src/supervisor/model.rs @@ -436,7 +436,10 @@ pub enum ConversationItem { total_cost_usd: Option, num_turns: Option, duration_ms: Option, - /// Cumulative model/API time this turn (the "N s of model" breakdown). + /// Model/API time spent during THIS turn (the "N s of model" breakdown), derived + /// from the CLI's cumulative per-session counter by the assembler. `None` when + /// unknown — the first turn of a resumed process (see + /// `Assembler::api_ms_baseline`) — or on a backend without the breakdown (Codex). duration_api_ms: Option, /// Time-to-first-token this turn; captured but not yet surfaced in the UI. ttft_ms: Option, diff --git a/src-tauri/src/supervisor/session.rs b/src-tauri/src/supervisor/session.rs index 8b2cae2e..463990f7 100644 --- a/src-tauri/src/supervisor/session.rs +++ b/src-tauri/src/supervisor/session.rs @@ -561,7 +561,13 @@ pub fn spawn_session( appmcp: Option>, ) -> Result { let (transport, msg_rx) = Transport::spawn(cfg.clone()).map_err(SessionError::Spawn)?; - let core = SessionCore::new(id.clone(), initial, emitter, transport.outbound(), appmcp); + let mut core = SessionCore::new(id.clone(), initial, emitter, transport.outbound(), appmcp); + // A brand-new session's cumulative model-time counter starts at zero, so even its + // first turn's model time is exact. A resumed / re-attached one restores an unknown + // total first (see `Assembler::api_ms_baseline`). + if cfg.resume.is_none() && cfg.attach.is_none() { + core.assembler.mark_fresh_session(); + } let (cmd_tx, cmd_rx) = mpsc::channel(64); tokio::spawn(run_actor(core, transport, msg_rx, cmd_rx, on_exit, cfg)); Ok(SessionHandle { id, cmd_tx }) diff --git a/src/agent/rowTiming.test.ts b/src/agent/rowTiming.test.ts index 793f976b..0d9b8803 100644 --- a/src/agent/rowTiming.test.ts +++ b/src/agent/rowTiming.test.ts @@ -49,6 +49,35 @@ describe("rowTiming", () => { expect(rowTiming(QUESTION, c)).toEqual({ mode: "paused", elapsedMs: 252_000 }); }); + it("counts from the run's Enter, not from a follow-up turn, when a run is known", () => { + // A follow-up turn (the CLI reporting on background work) restamped the turn clock. + const c = clock({ + turnStartedAt: 40_000, + lastTurnStartedAt: 40_000, + runLiveSince: 1000, + runStartedAt: 1000, + }); + expect(rowTiming(RUNNING, c)).toEqual({ mode: "live", startedAt: 1000 }); + }); + + it("counts background work from the Enter of the earliest run still running some", () => { + const c = clock({ backgroundSince: 5000, backgroundRunSince: 1000, lastTurnStartedAt: 400_000 }); + expect(rowTiming(BACKGROUNDING, c)).toEqual({ mode: "live", startedAt: 1000 }); + }); + + it("freezes a settled state on the whole run, follow-ups included", () => { + // The last turn alone (a 3s follow-up) is not what the run took. + const c = clock({ + lastTurnStartedAt: 250_000, + lastTurnEndedAt: 253_000, + runStartedAt: 1000, + runSettledAt: 253_000, + }); + expect(rowTiming(REVIEW, c)).toEqual({ mode: "paused", elapsedMs: 252_000 }); + expect(rowTiming(ERROR, c)).toEqual({ mode: "paused", elapsedMs: 252_000 }); + expect(rowTiming(QUESTION, c)).toEqual({ mode: "paused", elapsedMs: 252_000 }); + }); + it("freezes a blocked agent at the moment it asked, not at the turn's end", () => { const c = clock({ turnStartedAt: 1000, lastTurnStartedAt: 1000, awaitingSince: 9000 }); expect(rowTiming(PERMISSION, c)).toEqual({ mode: "paused", elapsedMs: 8000 }); diff --git a/src/agent/rowTiming.ts b/src/agent/rowTiming.ts index 512bd21f..f96cd2d2 100644 --- a/src/agent/rowTiming.ts +++ b/src/agent/rowTiming.ts @@ -26,6 +26,13 @@ export interface RowClock { /** When the background work still running started (earliest running task), `null` when * none is. Outlives the turn that launched it — and any turn run meanwhile. */ backgroundSince: number | null; + /** The RUN clock (`runClock.rowRunClock`): the same times counted from the user's Enter + * across the follow-up turns a run's background work triggers. Preferred over the turn + * fields above, which a follow-up turn restamps; absent/`null` when no run is known. */ + runLiveSince?: number | null; + runStartedAt?: number | null; + runSettledAt?: number | null; + backgroundRunSince?: number | null; } export type RowTiming = @@ -55,29 +62,37 @@ function frozen(from: number | null, to: number | null): RowTiming { */ export function rowTiming(status: AgentStatus, clock: RowClock | undefined): RowTiming { if (!clock) return status.kind === "idle" || status.kind === "off" ? NONE : PAUSED_UNKNOWN; + // Everything counts from the RUN when one is known (the user's Enter): a follow-up turn + // the CLI runs on its own to report on background work would otherwise restart the + // count. The turn fields are the fallback. + const liveSince = clock.runLiveSince ?? clock.turnStartedAt; + const settled = + clock.runStartedAt != null + ? frozen(clock.runStartedAt, clock.runSettledAt ?? null) + : frozen(clock.lastTurnStartedAt, clock.lastTurnEndedAt); switch (status.kind) { - // Working: the turn itself, or the background work that outlived it — which counts from - // the WORK's own start, not from a turn (a follow-up turn would restart that clock while - // the same work keeps running); the turn's start is only a fallback. + // Working: the run in flight, or the background work that outlived its turn — counted + // from the Enter of the earliest run still running some, else from the WORK's own + // start; the last turn's start is only a fallback. case "running": - return clock.turnStartedAt == null ? NONE : { mode: "live", startedAt: clock.turnStartedAt }; + return liveSince == null ? NONE : { mode: "live", startedAt: liveSince }; case "backgrounding": { - const since = clock.backgroundSince ?? clock.lastTurnStartedAt; + const since = clock.backgroundRunSince ?? clock.backgroundSince ?? clock.lastTurnStartedAt; return since == null ? NONE : { mode: "live", startedAt: since }; } // Blocked ON the user: nothing is happening, so freeze at the moment the agent asked. // The turn is usually still in flight — but a DETACHED sub-agent can ask after its turn // ended (`turnStartedAt` cleared), hence the fallback to that turn's start. case "needIntervention": - return frozen(clock.turnStartedAt ?? clock.lastTurnStartedAt, clock.awaitingSince); + return frozen(liveSince ?? clock.lastTurnStartedAt, clock.awaitingSince); case "needInput": return status.via === "questionnaire" - ? frozen(clock.turnStartedAt ?? clock.lastTurnStartedAt, clock.awaitingSince) - : frozen(clock.lastTurnStartedAt, clock.lastTurnEndedAt); - // Settled on a state the user has yet to clear: how long the turn took. + ? frozen(liveSince ?? clock.lastTurnStartedAt, clock.awaitingSince) + : settled; + // Settled on a state the user has yet to clear: how long the run took. case "error": case "review": - return frozen(clock.lastTurnStartedAt, clock.lastTurnEndedAt); + return settled; // Calm: no second line. Listed explicitly so a new status kind is a compile error // here until it is classified — same discipline as the other classifiers. case "idle": diff --git a/src/agent/runClock.test.ts b/src/agent/runClock.test.ts new file mode 100644 index 00000000..d77a5001 --- /dev/null +++ b/src/agent/runClock.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { + EMPTY_RUN_CLOCK, + FOLLOW_UP_WINDOW_MS, + liveRunStart, + rowRunClock, + runBusy, + runEndAll, + runFooterFor, + runResult, + runSend, + runTask, + settledRunMs, + type RunClock, +} from "./runClock"; + +/** A plain prompt: sent at `at`, turn from `at + 100`, answered at `endAt`. */ +function plainRun(clock: RunClock, at: number, resultId: string, endAt: number, modelMs: number | null = 1000) { + let c = runSend(clock, at, false); + c = runBusy(c, true, at + 100); + c = runResult(c, resultId, endAt, modelMs); + return runBusy(c, false, endAt); +} + +describe("runClock", () => { + it("times a plain prompt from the Enter to its answer", () => { + const c = plainRun(EMPTY_RUN_CLOCK, 1000, "tr_1", 7000); + expect(runFooterFor(c, "tr_1")).toEqual({ + mainMs: 6000, + totalMs: 6000, + backgroundSince: null, + backgroundCount: 0, + extended: false, + modelMs: 1000, + }); + expect(liveRunStart(c)).toBeNull(); + }); + + // The sequence a live probe of claude 2.1.283 produced for ONE prompt launching a + // background sub-agent and a background Bash: the main answer, then one follow-up + // turn the CLI starts on its own per task that reports back (no user line on the wire). + it("keeps ONE clock across the follow-up turns background work triggers", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "agent", true, 2900); + c = runTask(c, "bash", true, 5000); + c = runResult(c, "tr_1", 6900, 7900); + c = runBusy(c, false, 6900); + + // Main agent answered, background still running: frozen main time + live counter. + expect(runFooterFor(c, "tr_1")).toMatchObject({ + mainMs: 6900, + totalMs: null, + backgroundSince: 0, + backgroundCount: 2, + }); + + // The sub-agent reports back → the CLI wakes the main agent: same run, same start. + c = runTask(c, "agent", false, 8800); + c = runBusy(c, true, 9500); + expect(liveRunStart(c)).toBe(0); + // During the follow-up the working indicator carries the live count, not the footer. + expect(runFooterFor(c, "tr_1")).toMatchObject({ backgroundSince: null }); + c = runResult(c, "tr_2", 11400, 2500); + c = runBusy(c, false, 11400); + + // The footer moved under the latest result; the earlier one shows nothing. + expect(runFooterFor(c, "tr_1")).toBe("hidden"); + expect(runFooterFor(c, "tr_2")).toMatchObject({ mainMs: 6900, backgroundSince: 0, backgroundCount: 1 }); + + // The Bash finishes → its follow-up turn still belongs to this run. + c = runTask(c, "bash", false, 45000); + c = runBusy(c, true, 45700); + expect(liveRunStart(c)).toBe(0); + c = runResult(c, "tr_3", 46400, 1300); + c = runBusy(c, false, 46400); + + expect(runFooterFor(c, "tr_2")).toBe("hidden"); + expect(runFooterFor(c, "tr_3")).toEqual({ + mainMs: 6900, + totalMs: 46400, + backgroundSince: null, + backgroundCount: 0, + extended: true, + modelMs: 7900 + 2500 + 1300, + }); + expect(c.runs).toHaveLength(1); + }); + + it("marks a run whose background work outlived the answer as extended even with no follow-up", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 1000); + c = runResult(c, "tr_1", 2000, 500); + c = runBusy(c, false, 2000); + c = runTask(c, "bash", false, 9000); + expect(runFooterFor(c, "tr_1")).toMatchObject({ mainMs: 2000, totalMs: 9000, extended: true }); + }); + + it("does not extend a run for a FOREGROUND sub-agent that finished within the turn", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "fg-agent", true, 1000); + c = runTask(c, "fg-agent", false, 4000); + c = runResult(c, "tr_1", 5000, 800); + c = runBusy(c, false, 5000); + expect(runFooterFor(c, "tr_1")).toMatchObject({ mainMs: 5000, totalMs: 5000, extended: false }); + }); + + it("opens a new run for an autonomous turn long after a run that ended on its answer", () => { + let c = plainRun(EMPTY_RUN_CLOCK, 0, "tr_1", 5000); + // A scheduled wake-up, 20 minutes later: its own run, timed from its own start. + c = runBusy(c, true, 1_200_000); + expect(liveRunStart(c)).toBe(1_200_000); + c = runResult(c, "tr_2", 1_203_000, 900); + c = runBusy(c, false, 1_203_000); + expect(runFooterFor(c, "tr_1")).toMatchObject({ mainMs: 5000, totalMs: 5000 }); + expect(runFooterFor(c, "tr_2")).toMatchObject({ mainMs: 3000, totalMs: 3000, extended: false }); + }); + + it("joins a follow-up that races a task finishing mid-turn, within the window only", () => { + const base = (() => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 500); + c = runTask(c, "bash", false, 3000); // done while the main turn still runs + c = runResult(c, "tr_1", 4000, 700); + return runBusy(c, false, 4000); + })(); + const joined = runBusy(base, true, 4000 + FOLLOW_UP_WINDOW_MS); + expect(joined.runs).toHaveLength(1); + expect(liveRunStart(joined)).toBe(0); + const separate = runBusy(base, true, 4001 + FOLLOW_UP_WINDOW_MS); + expect(separate.runs).toHaveLength(2); + }); + + it("keeps a message sent mid-turn inside the run in flight", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + expect(runSend(c, 3000, true)).toBe(c); + // Busy by the clock's own account wins over a caller that thought it was idle. + expect(runSend(c, 3000, false)).toBe(c); + }); + + it("keeps an earlier run alive on its own background work while a new prompt runs", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "old-bash", true, 1000); + c = runResult(c, "tr_1", 2000, 400); + c = runBusy(c, false, 2000); + + // A new prompt while the first run's Bash still runs. + c = plainRun(c, 10_000, "tr_2", 14_000); + expect(runFooterFor(c, "tr_2")).toMatchObject({ mainMs: 4000, totalMs: 4000, extended: false }); + expect(runFooterFor(c, "tr_1")).toMatchObject({ backgroundSince: 0, backgroundCount: 1 }); + + // The first run ends when ITS work does. + c = runTask(c, "old-bash", false, 30_000); + expect(runFooterFor(c, "tr_1")).toMatchObject({ mainMs: 2000, totalMs: 30_000, extended: true }); + }); + + it("reopens a run when one of its tasks restarts (a sub-agent woken again)", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "agent", true, 500); + c = runResult(c, "tr_1", 1000, 300); + c = runBusy(c, false, 1000); + c = runTask(c, "agent", false, 5000); + expect(c.runs[0].endedAt).toBe(5000); + c = runTask(c, "agent", true, 6000); + expect(c.runs[0].endedAt).toBeNull(); + expect(runFooterFor(c, "tr_1")).toMatchObject({ backgroundSince: 0, backgroundCount: 1 }); + }); + + it("ends every run when the session goes away", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 500); + c = runResult(c, "tr_1", 1000, 300); + c = runBusy(c, false, 1000); + c = runEndAll(c, 8000); + expect(runFooterFor(c, "tr_1")).toMatchObject({ totalMs: 8000, backgroundSince: null }); + expect(liveRunStart(c)).toBeNull(); + expect(runEndAll(c, 9000)).toBe(c); + }); + + it("loses the run's model time once any of its turns reports none", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 500); + c = runResult(c, "tr_1", 1000, null); // first turn of a resumed process + c = runBusy(c, false, 1000); + c = runTask(c, "bash", false, 2000); + c = runBusy(c, true, 2100); + c = runResult(c, "tr_2", 3000, 600); + c = runBusy(c, false, 3000); + expect(runFooterFor(c, "tr_2")).toMatchObject({ modelMs: null }); + }); + + it("returns the SAME clock when a signal changes nothing (progress ticks re-render nothing)", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 500); + expect(runTask(c, "bash", true, 600)).toBe(c); + expect(runBusy(c, true, 700)).toBe(c); + // A result already attached is not re-attached (at-least-once delivery). + const r = runResult(c, "tr_1", 900, 100); + expect(runResult(r, "tr_1", 950, 100)).toBe(r); + }); + + it("leaves results and tasks alone when no run exists", () => { + expect(runResult(EMPTY_RUN_CLOCK, "tr_1", 1000, 100)).toBe(EMPTY_RUN_CLOCK); + expect(runTask(EMPTY_RUN_CLOCK, "bash", true, 1000)).toBe(EMPTY_RUN_CLOCK); + expect(runFooterFor(EMPTY_RUN_CLOCK, "tr_1")).toBeNull(); + expect(runFooterFor(undefined, "tr_1")).toBeNull(); + }); + + it("reports the settled run's whole span, and nothing while it runs", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 500); + c = runResult(c, "tr_1", 3000, 300); + c = runBusy(c, false, 3000); + expect(settledRunMs(c)).toBeNull(); + c = runTask(c, "bash", false, 20_000); + c = runBusy(c, true, 20_500); + c = runResult(c, "tr_2", 22_000, 400); + c = runBusy(c, false, 22_000); + expect(settledRunMs(c)).toBe(22_000); + expect(settledRunMs(undefined)).toBeNull(); + }); + + it("gives the sidebar row the run's times", () => { + let c = runSend(EMPTY_RUN_CLOCK, 0, false); + c = runBusy(c, true, 100); + c = runTask(c, "bash", true, 500); + c = runResult(c, "tr_1", 3000, 300); + c = runBusy(c, false, 3000); + expect(rowRunClock(c)).toEqual({ + runLiveSince: 0, + runStartedAt: 0, + runSettledAt: 3000, + backgroundRunSince: 0, + }); + c = runTask(c, "bash", false, 9000); + expect(rowRunClock(c)).toEqual({ + runLiveSince: null, + runStartedAt: 0, + runSettledAt: 9000, + backgroundRunSince: null, + }); + expect(rowRunClock(undefined)).toEqual({ + runLiveSince: null, + runStartedAt: null, + runSettledAt: null, + backgroundRunSince: null, + }); + }); +}); diff --git a/src/agent/runClock.ts b/src/agent/runClock.ts new file mode 100644 index 00000000..6f4cfc4d --- /dev/null +++ b/src/agent/runClock.ts @@ -0,0 +1,334 @@ +// The RUN clock: how long a prompt took, measured from the user's Enter to the moment +// everything it set in motion was done. +// +// A turn is the wrong unit for that. Verified live (claude 2.1.283): one prompt that +// launches background work (a detached sub-agent, a background Bash, a Monitor, a +// workflow…) produces SEVERAL `result`s — the main answer, then one follow-up turn per +// background task that reports back, which the CLI starts on its own (no user line on +// the wire). Timing each turn restarted the live counter on every follow-up and printed +// a short, unrelated duration under each of them ("6s", then "2s", "2s", "1s"). +// +// A run groups all of it: +// - it OPENS on the user's send (a message sent mid-turn joins the turn in flight); +// - it owns the background tasks first seen while it was the latest run; +// - a turn the CLI starts on its own JOINS the latest run while that run still has +// work running, or when its last task just finished (the CLI is waking the agent to +// report on it) — any other autonomous turn (a scheduled wake-up…) opens a run of +// its own; +// - it ENDS the moment nothing of it is left: its main loop is idle and none of its +// tasks runs. A task that restarts (a sub-agent woken again) reopens it. +// +// Pure and immutable (the store holds one clock per conversation, the UI only reads it), +// so the rules are pinned by tests. Every transition returns the SAME object when nothing +// changes: task snapshots arrive on every progress tick and must not re-render anything. + +/** One prompt and everything it set in motion. */ +export interface Run { + /** Wall-clock start: the user's Enter, or the busy edge of an autonomous turn. */ + startedAt: number; + /** The run's first `turn_result` (the main agent's answer) and when it landed. */ + mainResultId: string | null; + mainEndedAt: number | null; + /** The run's latest `turn_result` — where its footer renders — and when it landed. */ + lastResultId: string | null; + lastResultAt: number | null; + /** Model time summed over the run's results; `null` once any of them is unknown. */ + modelMs: number | null; + /** The run's background tasks still running. */ + running: string[]; + /** The run went through a background phase: its main loop idle, work still running. */ + backgrounded: boolean; + /** Turns the run has started (its main turn + the follow-ups it was woken for). */ + turns: number; + /** When the run went fully quiet; `null` while any of it is still going. */ + endedAt: number | null; + /** What made it quiet: its last TASK finishing (the CLI is about to wake the agent to + * report on it, and that follow-up belongs here) or a turn ending with nothing left. */ + quietBy: "result" | "task" | null; + /** When one of its tasks last finished with no turn of the run started since — the CLI + * owes it a follow-up turn to report on that task. Cleared when a turn of it starts. */ + wakePendingSince: number | null; +} + +/** One conversation's runs, plus the live signals they are settled against. */ +export interface RunClock { + /** Chronological. The last one is "the latest run". */ + runs: Run[]; + /** `turn_result` id → index of its run in {@link runs}. */ + resultRun: Record; + /** Background task id → index of the run that owns it. */ + taskRun: Record; + /** The main loop is running a turn. */ + busy: boolean; + /** The user's send opened the latest run and its turn has not started yet. */ + awaitingTurn: boolean; +} + +export const EMPTY_RUN_CLOCK: RunClock = { + runs: [], + resultRun: {}, + taskRun: {}, + busy: false, + awaitingTurn: false, +}; + +function newRun(startedAt: number, turns: number): Run { + return { + startedAt, + mainResultId: null, + mainEndedAt: null, + lastResultId: null, + lastResultAt: null, + modelMs: 0, + running: [], + backgrounded: false, + turns, + endedAt: null, + quietBy: null, + wakePendingSince: null, + }; +} + +/** How long after a run went quiet an autonomous turn may still be its follow-up when one + * of its tasks finished WHILE a turn ran (the report may have been folded into that turn, + * or may come as a turn of its own right after). When the run went quiet BECAUSE a task + * finished, the follow-up is certain and no window applies. */ +export const FOLLOW_UP_WINDOW_MS = 30_000; + +/** The latest run's main loop is working (or about to, right after a send). */ +function mainLoopActive(clock: RunClock, idx: number): boolean { + return idx === clock.runs.length - 1 && (clock.busy || clock.awaitingTurn); +} + +/** Re-evaluate run `idx` against the clock's signals: end it when nothing of it is left + * (stamping why), reopen it when something of it is going again, and note a background + * phase. Returns the same run object when nothing moved. */ +function settleRun(clock: RunClock, idx: number, now: number, cause: "result" | "task"): Run { + const run = clock.runs[idx]; + const loop = mainLoopActive(clock, idx); + const active = loop || run.running.length > 0; + let next = run; + if (!loop && run.running.length > 0 && !run.backgrounded) next = { ...next, backgrounded: true }; + if (!active && next.endedAt == null) next = { ...next, endedAt: now, quietBy: cause }; + else if (active && next.endedAt != null) next = { ...next, endedAt: null, quietBy: null }; + return next; +} + +/** Replace run `idx`, keeping the clock identical when the run did not change. */ +function withRun(clock: RunClock, idx: number, run: Run): RunClock { + if (clock.runs[idx] === run) return clock; + const runs = clock.runs.slice(); + runs[idx] = run; + return { ...clock, runs }; +} + +/** Settle every run (a signal that can end several at once: a new run makes the previous + * one "not latest", a session end stops everything). */ +function settleAll(clock: RunClock, now: number, cause: "result" | "task"): RunClock { + let out = clock; + for (let i = 0; i < clock.runs.length; i++) out = withRun(out, i, settleRun(out, i, now, cause)); + return out; +} + +/** + * The user sent a message. Sent while a turn is in flight (`midTurn`), it is injected + * into that turn — same run. Otherwise it opens a new run; the previous one stays alive + * as long as its own background tasks run. + */ +export function runSend(clock: RunClock, now: number, midTurn: boolean): RunClock { + // Busy by the clock's own account counts too: whatever the caller believed, the CLI + // queues a message sent during a turn into that turn. + if (midTurn || clock.busy) return clock; + const opened: RunClock = { + ...clock, + runs: [...clock.runs, newRun(now, 0)], + awaitingTurn: true, + }; + return settleAll(opened, now, "result"); +} + +/** + * The main loop's busy flag moved. A turn starting right after a send is that send's + * turn; any other start is the CLI's own doing — it joins the latest run while that run + * is still going or was just emptied by a finishing task, and opens a run otherwise. + */ +export function runBusy(clock: RunClock, busy: boolean, now: number): RunClock { + if (busy === clock.busy) return clock; + if (!busy) { + const idle: RunClock = { ...clock, busy: false }; + const last = idle.runs.length - 1; + return last < 0 ? idle : withRun(idle, last, settleRun(idle, last, now, "result")); + } + const last = clock.runs.length - 1; + const latest = last >= 0 ? clock.runs[last] : null; + if (clock.awaitingTurn && latest) { + const started: RunClock = { ...clock, busy: true, awaitingTurn: false }; + return withRun(started, last, { ...latest, turns: latest.turns + 1, wakePendingSince: null }); + } + const joins = + latest != null && + (latest.endedAt == null || + latest.quietBy === "task" || + (latest.wakePendingSince != null && now - latest.endedAt <= FOLLOW_UP_WINDOW_MS)); + if (joins) { + const started: RunClock = { ...clock, busy: true, awaitingTurn: false }; + const run = { ...latest, turns: latest.turns + 1, wakePendingSince: null }; + return withRun(started, last, settleRun(withRun(started, last, run), last, now, "result")); + } + const opened: RunClock = { + ...clock, + busy: true, + awaitingTurn: false, + runs: [...clock.runs, newRun(now, 1)], + }; + return settleAll(opened, now, "result"); +} + +/** + * A turn finished (`turn_result` `resultId`), with the model time it spent (`null` when + * unknown). It belongs to the latest run: the first one is the main answer, each later + * one moves the run's footer down to it. Ignored when no run exists (a turn already in + * flight before this app instance saw it start) — its footer then falls back to the + * turn's own duration. + */ +export function runResult( + clock: RunClock, + resultId: string, + now: number, + modelMs: number | null, +): RunClock { + const last = clock.runs.length - 1; + if (last < 0 || clock.resultRun[resultId] != null) return clock; + const run = clock.runs[last]; + const next: Run = { + ...run, + mainResultId: run.mainResultId ?? resultId, + mainEndedAt: run.mainEndedAt ?? now, + lastResultId: resultId, + lastResultAt: now, + modelMs: run.modelMs == null || modelMs == null ? null : run.modelMs + modelMs, + }; + return withRun({ ...clock, resultRun: { ...clock.resultRun, [resultId]: last } }, last, next); +} + +/** + * A background task snapshot: `taskId` is (still) running or not. A task is owned by + * the run that was latest when it was first seen. Its stop can end that run; a restart + * (a sub-agent woken again) reopens it. + */ +export function runTask(clock: RunClock, taskId: string, running: boolean, now: number): RunClock { + let owner = clock.taskRun[taskId]; + let base = clock; + if (owner == null) { + if (clock.runs.length === 0) return clock; + owner = clock.runs.length - 1; + base = { ...clock, taskRun: { ...clock.taskRun, [taskId]: owner } }; + } + const run = base.runs[owner]; + const has = run.running.includes(taskId); + if (running === has) return base; + const nextRun: Run = { + ...run, + running: running ? [...run.running, taskId] : run.running.filter((id) => id !== taskId), + wakePendingSince: running ? run.wakePendingSince : now, + }; + const moved = withRun(base, owner, nextRun); + return withRun(moved, owner, settleRun(moved, owner, now, "task")); +} + +/** The session is gone (process ended, or turned off): nothing of any run can still be + * running, so every open run ends now. */ +export function runEndAll(clock: RunClock, now: number): RunClock { + if (!clock.busy && !clock.awaitingTurn && clock.runs.every((r) => r.endedAt != null)) return clock; + const runs = clock.runs.map((r) => + r.endedAt != null ? r : { ...r, running: [], endedAt: now, quietBy: "result" as const }, + ); + return { ...clock, runs, busy: false, awaitingTurn: false }; +} + +// ---- reading it ------------------------------------------------------------------ + +/** What a `turn_result` footer shows for its run. */ +export interface RunFooter { + /** Enter → the main agent's answer. */ + mainMs: number; + /** Enter → the last of the run's work, once it is all done; `null` while it runs. */ + totalMs: number | null; + /** Start of the live counter while the main agent is done but background work still + * runs (the counter reads from the Enter), `null` otherwise. */ + backgroundSince: number | null; + /** Background tasks still running (the count shown next to the live counter). */ + backgroundCount: number; + /** The run outlived its main answer (background phase or follow-up turns): its total + * is worth showing next to the main time. */ + extended: boolean; + /** Model time over the run's turns, `null` when unknown. */ + modelMs: number | null; +} + +/** + * The footer of `turn_result` `resultId`: `null` when its run is unknown (the caller + * falls back to the turn's own duration), `"hidden"` for a result that is not its run's + * latest (the run's single footer sits under its latest result, never one per turn). + */ +export function runFooterFor(clock: RunClock | undefined, resultId: string): RunFooter | "hidden" | null { + if (!clock) return null; + const idx = clock.resultRun[resultId]; + if (idx == null) return null; + const run = clock.runs[idx]; + if (run.lastResultId !== resultId) return "hidden"; + const live = run.endedAt == null; + const backgroundPhase = live && run.running.length > 0 && !mainLoopActive(clock, idx); + return { + mainMs: Math.max(0, (run.mainEndedAt ?? run.startedAt) - run.startedAt), + totalMs: live ? null : Math.max(0, run.endedAt! - run.startedAt), + backgroundSince: backgroundPhase ? run.startedAt : null, + backgroundCount: backgroundPhase ? run.running.length : 0, + extended: run.backgrounded || run.turns > 1, + modelMs: run.modelMs, + }; +} + +/** Start of the latest run while it is still going (the live "working" counter reads + * from here, so a follow-up turn never restarts it), `null` otherwise. */ +export function liveRunStart(clock: RunClock | undefined): number | null { + const latest = clock?.runs[clock.runs.length - 1]; + return latest && latest.endedAt == null ? latest.startedAt : null; +} + +/** How long the latest run took, Enter → the last of its work, once it is done and has + * answered; `null` otherwise (the caller falls back to the last turn's own duration). */ +export function settledRunMs(clock: RunClock | undefined): number | null { + const latest = clock?.runs[clock.runs.length - 1]; + if (!latest || latest.endedAt == null || latest.lastResultId == null) return null; + return Math.max(0, latest.endedAt - latest.startedAt); +} + +/** The run timing a sidebar row needs (see `rowTiming`). All primitives, so a shallow + * selector over it re-renders only when one of them moves. */ +export interface RowRunClock { + /** The latest run's start, while it is still going. */ + runLiveSince: number | null; + /** The latest run's span once it settled: start → end (or → its latest answer while + * background work still runs). `null` ends when unknown. */ + runStartedAt: number | null; + runSettledAt: number | null; + /** Earliest start among the runs whose background work still runs. */ + backgroundRunSince: number | null; +} + +export function rowRunClock(clock: RunClock | undefined): RowRunClock { + const latest = clock?.runs[clock.runs.length - 1]; + let backgroundRunSince: number | null = null; + for (const r of clock?.runs ?? []) { + if (r.endedAt == null && r.running.length > 0) { + if (backgroundRunSince == null || r.startedAt < backgroundRunSince) backgroundRunSince = r.startedAt; + } + } + return { + runLiveSince: latest && latest.endedAt == null ? latest.startedAt : null, + runStartedAt: latest?.startedAt ?? null, + runSettledAt: latest ? (latest.endedAt ?? latest.lastResultAt) : null, + backgroundRunSince, + }; +} diff --git a/src/agent/subagentMeta.test.ts b/src/agent/subagentMeta.test.ts index f2391621..ad3140ea 100644 --- a/src/agent/subagentMeta.test.ts +++ b/src/agent/subagentMeta.test.ts @@ -2,11 +2,26 @@ import { describe, it, expect } from "vitest"; import { effortLabel, EFFORT_LABELS, + fmtDuration, isDetachedAgentAck, runIdFromResult, shortModel, } from "./subagentMeta"; +describe("fmtDuration", () => { + it("formats ms, seconds, then minutes + seconds", () => { + expect(fmtDuration(850)).toBe("850ms"); + expect(fmtDuration(6300)).toBe("6.3s"); + expect(fmtDuration(46_000)).toBe("46s"); + expect(fmtDuration(220_000)).toBe("3m 40s"); + }); + + it("never prints an impossible 60 seconds", () => { + expect(fmtDuration(119_600)).toBe("2m 00s"); + expect(fmtDuration(179_700)).toBe("3m 00s"); + }); +}); + describe("effortLabel", () => { it("maps each CLI effort level to its display label", () => { expect(effortLabel("low")).toBe("Low"); diff --git a/src/agent/subagentMeta.ts b/src/agent/subagentMeta.ts index 581ad0af..526cbd59 100644 --- a/src/agent/subagentMeta.ts +++ b/src/agent/subagentMeta.ts @@ -44,8 +44,11 @@ export function fmtDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const s = ms / 1000; if (s < 60) return `${s % 1 === 0 ? s.toFixed(0) : s.toFixed(1)}s`; - const m = Math.floor(s / 60); - const rem = Math.round(s % 60); + // Round the WHOLE duration to seconds before splitting it: rounding only the remainder + // printed an impossible "1m 60s" for 119.6s. + const total = Math.round(s); + const m = Math.floor(total / 60); + const rem = total % 60; return `${m}m ${rem.toString().padStart(2, "0")}s`; } diff --git a/src/features/conversation/ComposerStatusBand.tsx b/src/features/conversation/ComposerStatusBand.tsx index 74f26a4f..fd4fb72a 100644 --- a/src/features/conversation/ComposerStatusBand.tsx +++ b/src/features/conversation/ComposerStatusBand.tsx @@ -16,6 +16,7 @@ import { acknowledgeConversation } from "../../store/conversationsStore"; import { useConversationStore } from "../../store/conversationStore"; import { useSendMessage } from "../../ipc/useCommands"; import { fmtDuration } from "../../agent/subagentMeta"; +import { settledRunMs } from "../../agent/runClock"; import { Ico } from "../../ui/kit"; /** @@ -46,16 +47,19 @@ export function useMarkSeenShortcut(session: string, active: boolean) { }, [active, session]); } -/** The finished turn's wall-clock, next to "Conversation ended". Its own leaf, mounted only - * in the review state. Live-only: a conversation restored from disk has no `turn_result` - * (and is already marked seen), so it shows nothing — and the timeline scan is gated on - * the same "settled and unseen" condition as `gather` in useAgentStatus, so it never walks - * a restored timeline on every store update. */ +/** How long the finished RUN took (Enter → the last of the background work it launched), + * next to "Conversation ended" — not the last turn alone, which after background work is + * just the short follow-up the CLI ran to report on it. Falls back to that last turn's + * duration when no run is known. Its own leaf, mounted only in the review state. + * Live-only: a conversation restored from disk has no `turn_result` (and is already marked + * seen), so it shows nothing — and the timeline scan is gated on the same "settled and + * unseen" condition as `gather` in useAgentStatus, so it never walks a restored timeline on + * every store update. */ function EndedAfter({ session }: { session: string }) { const ms = useConversationStore((s) => { const entry = s.sessions[session]; if (!entry || entry.turnSeen || entry.state.busy) return null; - return lastTurnResultMeta(entry)?.durationMs ?? null; + return settledRunMs(entry.runClock) ?? lastTurnResultMeta(entry)?.durationMs ?? null; }); if (ms == null) return null; return ( diff --git a/src/features/conversation/ConductorSidebar.tsx b/src/features/conversation/ConductorSidebar.tsx index af6bb741..a5c6b50a 100644 --- a/src/features/conversation/ConductorSidebar.tsx +++ b/src/features/conversation/ConductorSidebar.tsx @@ -28,6 +28,7 @@ import { } from "../../agent/status"; import { useConversationStore } from "../../store/conversationStore"; import { rowTiming } from "../../agent/rowTiming"; +import { rowRunClock } from "../../agent/runClock"; import { fmtFrozenElapsed, useLiveElapsed } from "../../ui/liveElapsed"; import { useShallow } from "zustand/react/shallow"; import { useSettingsUi } from "../../store/settingsUi"; @@ -74,6 +75,7 @@ function RowLive({ convId, status }: { convId: string; status: AgentStatus }) { lastTurnStartedAt: e.lastTurnStartedAt, lastTurnEndedAt: e.lastTurnEndedAt, awaitingSince: e.awaitingSince, + ...rowRunClock(e.runClock), } : undefined; }), diff --git a/src/features/conversation/ConductorThread.module.css b/src/features/conversation/ConductorThread.module.css index 580f23cb..b884dcb0 100644 --- a/src/features/conversation/ConductorThread.module.css +++ b/src/features/conversation/ConductorThread.module.css @@ -315,6 +315,40 @@ margin-right: 5px; opacity: 0.6; } +/* A run that outlived its main answer: "— 3m 40s total" (Enter → last background task), + and while that work still runs, a live "— Background task running · 1m 12s" in the + green of the calm "backgrounding" state. The dash ties it to the frozen main time. */ +.turnMetaTotal::before, +.turnMetaBg::before { + content: "—"; + margin-right: 5px; + opacity: 0.6; + color: var(--wf-tx-lo); +} +.turnMetaBg { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--wf-run); +} +.turnMetaBgDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--wf-run); + animation: turn-meta-bg-pulse 1.6s ease-in-out infinite; +} +@keyframes turn-meta-bg-pulse { + 50% { opacity: 0.35; } +} +@media (prefers-reduced-motion: reduce) { + .turnMetaBgDot { animation: none; } +} +.turnMetaBgTime::before { + content: "·"; + margin-right: 5px; + opacity: 0.6; +} /* Live elapsed counter at the right of the working indicator (shown on long turns). */ .elapsed { diff --git a/src/features/conversation/ConductorThread.tsx b/src/features/conversation/ConductorThread.tsx index 3ca8c370..d333eefc 100644 --- a/src/features/conversation/ConductorThread.tsx +++ b/src/features/conversation/ConductorThread.tsx @@ -33,11 +33,13 @@ import { useToolResult, useTurn, useTurnResult, - useTurnStartedAt, + useRunFooter, + useRunStartedAt, useThinkingStartedAt, useThinkingDuration, } from "../../store/conversationStore"; import type { RoundMarker, UserTurnImage } from "../../store/types"; +import type { RunFooter } from "../../agent/runClock"; import { imageDataUrl } from "./composerAttachments"; import { useConversationsStore, rewindConversation, forkConversation } from "../../store/conversationsStore"; import { ConfirmDialog } from "../../ui/ConfirmDialog"; @@ -352,28 +354,97 @@ function resultToText(result: JsonValue | null): string | null { function TurnResultRow({ session, resultId }: { session: string; resultId: string }) { const meta = useTurnResult(session, resultId); + const run = useRunFooter(session, resultId); const showTurnDuration = useDisplay((s) => s.showTurnDuration); const showModelTime = useDisplay((s) => s.showModelTime); if (!meta) return null; const isError = meta.isError || meta.subtype.startsWith("error"); + // A run renders ONE timing line, under its latest result ("hidden" on the earlier ones): + // one line per turn is what made the duration look like it kept resetting, since each + // follow-up turn the CLI runs on its own to report on a background task got its own + // short number. No run known (a turn already in flight when this app saw it start): + // fall back to the turn's own duration (duration_ms) and model time. No cost/TTFT shown. + let timing: ReactNode = null; + if (showTurnDuration && run !== "hidden") { + if (run) { + timing = ; + } else if (meta.durationMs != null) { + timing = ( +
+ + {fmtDuration(meta.durationMs)} + {showModelTime && meta.durationApiMs != null && meta.durationApiMs > 0 && ( + {fmtDuration(meta.durationApiMs)} model + )} +
+ ); + } + } if (isError) { const text = resultToText(meta.result); - return {text}; + return ( + <> + {text} + {/* An errored turn shows no duration of its own — but when it closes a run that + outlived its main answer, that run's timing must not vanish with it. */} + {run && run !== "hidden" && run.extended ? timing : null} + + ); } - // Success / interrupted: optionally surface the wall-clock time the turn took - // (duration_ms) plus the model-time breakdown (duration_api_ms). No cost/TTFT shown. - if (!showTurnDuration || meta.durationMs == null) return null; + return timing; +} + +/** Whole seconds, for a counter that ticks once a second ("0s" before the first one). */ +function fmtWholeSeconds(ms: number): string { + return ms < 1000 ? "0s" : fmtDuration(Math.floor(ms / 1000) * 1000); +} + +/** + * A run's timing line, everything counted from the user's Enter: + * - the MAIN time — until the main agent answered — frozen as soon as it did; + * - while background work it launched still runs: "— Background task running · 1m 12s", + * a live counter since the Enter, in the green of the calm "backgrounding" state; + * - once all of it is done, if the run outlived its answer: "— 3m 40s total", until the + * last background task (and the follow-up turns it triggered) finished; + * - the model time over the whole run, once it is done. + */ +function RunTiming({ footer, showModel }: { footer: RunFooter; showModel: boolean }) { + const done = footer.totalMs != null; + const total = footer.extended && done ? footer.totalMs : null; + const title = + footer.backgroundSince != null + ? "Time until the main answer · background work still running (counted from your message)" + : total != null + ? `${fmtDuration(footer.mainMs)} until the main answer · ${fmtDuration(total)} until the last background task finished` + : "Time from your message until the answer · including model time"; return ( -
+
- {fmtDuration(meta.durationMs)} - {showModelTime && meta.durationApiMs != null && meta.durationApiMs > 0 && ( - {fmtDuration(meta.durationApiMs)} model + {fmtDuration(footer.mainMs)} + {footer.backgroundSince != null ? ( + + ) : null} + {total != null ? {fmtDuration(total)} total : null} + {showModel && done && footer.modelMs != null && footer.modelMs > 0 && ( + {fmtDuration(footer.modelMs)} model )}
); } +/** The live part of a run's timing line while its background work runs. Its own leaf, so + * the 1 Hz tick re-renders this span only. */ +function BackgroundElapsed({ since, count }: { since: number; count: number }) { + const now = useNow(1000); + return ( + + + ); +} + /** Map a sub-agent's coarse lifecycle onto the design's status-dot colour token. */ function taskDotState(o: { running: boolean; failed: boolean; stopped: boolean }): StreamState { @@ -1579,13 +1650,15 @@ const TURN_ELAPSED_MIN_MS = 40_000; * activity text. Whole seconds only (floored) so the number climbs smoothly. */ function LiveElapsed({ session }: { session: string }) { const show = useDisplay((s) => s.showTurnDuration); - const startedAt = useTurnStartedAt(session); + // The RUN's start (the user's Enter), not the turn's: a follow-up turn the CLI starts on + // its own to report on background work continues the same count instead of restarting it. + const startedAt = useRunStartedAt(session); const now = useNow(1000); if (!show || startedAt == null) return null; const elapsed = now - startedAt; if (elapsed < TURN_ELAPSED_MIN_MS) return null; return ( - + {fmtDuration(Math.floor(elapsed / 1000) * 1000)} diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index 5ed32755..cf623506 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -1154,9 +1154,10 @@ function TimingPrefs() { title="Turn duration" hint={ <> - Under each finished turn, the total time it took; and a{" "} - live counter when a turn runs past 40 s.{" "} - On by default. + Under each answer, the time since your message — and, when + background tasks outlive the answer, a live counter until they finish, then the{" "} + total. Plus a live counter while the agent works + past 40 s. On by default. } checked={showTurnDuration} diff --git a/src/ipc/useGlobalSessionEvents.ts b/src/ipc/useGlobalSessionEvents.ts index 1a1f322a..45007af0 100644 --- a/src/ipc/useGlobalSessionEvents.ts +++ b/src/ipc/useGlobalSessionEvents.ts @@ -686,6 +686,8 @@ export function useGlobalSessionEvents(): void { useBackgroundTasksStore.getState().sessions[session]?.[task.task_id]?.status; // (1) registry: the core emits a full cumulative snapshot per task (replace by id). useBackgroundTasksStore.getState().applyTask(session, task); + // (1a) run clock: a run lasts until the last background task it launched is done. + useConversationStore.getState().noteTask(session, task.task_id, task.status === "running"); if (prevStatus === "running" && task.status !== "running") { const conv = useConversationsStore.getState().conversations.find((c) => c.id === session); void commands.publishControlEvent("task_finished", session, conv?.name ?? "", { diff --git a/src/store/conversationStore.ts b/src/store/conversationStore.ts index e5364564..6662302f 100644 --- a/src/store/conversationStore.ts +++ b/src/store/conversationStore.ts @@ -43,6 +43,17 @@ import { isBackgroundAgentInput, isDetachedAgentAck } from "../agent/subagentMet import { latestTodosInBlocks, todoSummary } from "./todos"; import { THINKING_ACCRUAL_CAP_MS } from "./thinkingWords"; import { parseSpecialMessage } from "../features/conversation/specialMessage"; +import { + EMPTY_RUN_CLOCK, + liveRunStart, + runBusy, + runEndAll, + runFooterFor, + runResult, + runSend, + runTask, + type RunFooter, +} from "../agent/runClock"; const connectingState: SessionStatePayload = { busy: false, @@ -84,6 +95,7 @@ function emptyEntry(session: string): SessionEntry { seq: 0, replayAnchor: 0, turnStartedAt: null, + runClock: EMPTY_RUN_CLOCK, lastTurnStartedAt: null, lastTurnEndedAt: null, awaitingSince: null, @@ -210,6 +222,10 @@ interface ConversationState { reanchorReplay: (session: string) => void; /** Forget a session's timeline entirely (e.g. its conversation was deleted). */ dropSession: (session: string) => void; + /** A background task snapshot, for the run clock: `taskId` is (still) running or not. + * No-op for a session the store does not hold, and when the task's state is unchanged + * (snapshots arrive on every progress tick). */ + noteTask: (session: string, taskId: string, running: boolean) => void; } export const useConversationStore = create((set) => { @@ -342,9 +358,14 @@ export const useConversationStore = create((set) => { } else if (!state.awaiting_permission && entry.state.awaiting_permission) { awaitingSince = null; } + // The run clock follows the same busy edges (a no-op on a mid-turn re-emit), and a + // process that ENDED can leave nothing of any run running. + let runClock = runBusy(entry.runClock, state.busy, Date.now()); + if (state.ended && !entry.state.ended) runClock = runEndAll(runClock, Date.now()); return { ...entry, turnStartedAt, + runClock, lastTurnStartedAt, lastTurnEndedAt, awaitingSince, @@ -378,6 +399,8 @@ export const useConversationStore = create((set) => { ...entry, state: { ...connectingState }, turnStartedAt: null, + // The process is going away with everything it was running. + runClock: runEndAll(entry.runClock, Date.now()), awaitingSince: null, thinkingSince: null, // seal the open spinner spell (kept thinkingMs = per-discussion total) thinkingStartedAt: null, @@ -443,6 +466,8 @@ export const useConversationStore = create((set) => { // Sending the next message consumes any pending review/question: the // user has clearly moved on from the previous result. turnSeen: true, + // The Enter a run is timed from. A message sent mid-turn joins the run in flight. + runClock: runSend(entry.runClock, Date.now(), !!queued), }; }); return createdId; @@ -531,6 +556,15 @@ export const useConversationStore = create((set) => { return { sessions: next }; }), + noteTask: (session, taskId, running) => + set((s) => { + const entry = s.sessions[session]; + if (!entry) return s; + const runClock = runTask(entry.runClock, taskId, running, Date.now()); + if (runClock === entry.runClock) return s; + return { sessions: { ...s.sessions, [session]: { ...entry, runClock } } }; + }), + applyItem: (session, item, hydrating = false) => withEntry(session, (entry) => { switch (item.kind) { @@ -778,6 +812,10 @@ export const useConversationStore = create((set) => { turns: touched ? turns : entry.turns, turnResults: { ...entry.turnResults, [id]: meta }, timeline: [...entry.timeline, { kind: "turn_result", id }], + // Attach it to its run (a history replay carries no results; guarded anyway). + runClock: hydrating + ? entry.runClock + : runResult(entry.runClock, id, Date.now(), item.duration_api_ms), openBubble: {}, pendingPermissions: [], // Re-anchor the replay insert point to the (new) end of the timeline at @@ -839,10 +877,21 @@ const EMPTY_STRINGS: string[] = []; export const useSessionState = (session: string): SessionStatePayload | undefined => useConversationStore((s) => s.sessions[session]?.state); -/** Wall-clock start of the in-flight turn (`Date.now()`), or `null` when idle. Drives - * the live elapsed counter in the working indicator. See {@link SessionEntry.turnStartedAt}. */ -export const useTurnStartedAt = (session: string): number | null => - useConversationStore((s) => s.sessions[session]?.turnStartedAt ?? null); +/** Start of the run in flight — the user's Enter, NOT the current turn's start, so a + * follow-up turn the CLI runs on its own never restarts the count — falling back to the + * turn's start when no run is known; `null` when idle. Drives the live elapsed counter + * in the working indicator. See {@link SessionEntry.runClock}. */ +export const useRunStartedAt = (session: string): number | null => + useConversationStore((s) => { + const e = s.sessions[session]; + return e ? (liveRunStart(e.runClock) ?? e.turnStartedAt) : null; + }); + +/** What the footer of `turn_result` `resultId` shows for its run (see `runFooterFor`): + * `null` = no run known (show the turn's own duration), `"hidden"` = not its run's latest + * result. Shallow-compared, so the footer re-renders only when a figure moves. */ +export const useRunFooter = (session: string, resultId: string): RunFooter | "hidden" | null => + useConversationStore(useShallow((s) => runFooterFor(s.sessions[session]?.runClock, resultId))); /** Wall-clock start of the thinking block currently streaming, or `null`. Drives the live * counter on a streaming ThinkingBlock. See {@link SessionEntry.thinkingStartedAt}. */ diff --git a/src/store/display.ts b/src/store/display.ts index ef975745..1d6abf7a 100644 --- a/src/store/display.ts +++ b/src/store/display.ts @@ -256,15 +256,18 @@ export interface DisplayPrefs { * Read by {@link ClientAvatar}. */ tosseClientFavicons: boolean; - /** Show the TURN's own timing in the conversation thread. Gates two surfaces: the total - * wall-clock in the FINISHED-turn footer (`result.duration_ms`) — {@link TurnResultRow}; - * AND the LIVE elapsed counter on a running turn past the threshold — {@link LiveElapsed}. - * ON by default. Off → neither is rendered. */ + /** Show a prompt's RUN timing in the conversation thread, counted from the user's Enter + * (see `agent/runClock.ts`). Gates two surfaces: the timing line under a run's latest + * result — main-answer time, then a live "background task running" counter or the total + * once its background work is done — {@link TurnResultRow}; AND the LIVE elapsed counter + * while the agent works past the threshold — {@link LiveElapsed}. ON by default. Off → + * neither is rendered. */ showTurnDuration: boolean; - /** Show the "· N s of model" breakdown (`result.duration_api_ms`) next to the turn's - * total in the footer. Rides the footer, so only visible when {@link showTurnDuration} is - * also on. ON by default. Read by {@link TurnResultRow}. */ + /** Show the "· N s of model" breakdown (the per-turn share of the CLI's cumulative + * `result.duration_api_ms`, summed over the run) next to the run's time in the footer. + * Rides the footer, so only visible when {@link showTurnDuration} is also on. ON by + * default. Read by {@link TurnResultRow}. */ showModelTime: boolean; /** Show the reflection time on each thinking block — a live counter while thinking, frozen diff --git a/src/store/runClockStore.test.ts b/src/store/runClockStore.test.ts new file mode 100644 index 00000000..5d9a0b32 --- /dev/null +++ b/src/store/runClockStore.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConversationItem } from "../ipc/client"; +import { runFooterFor, liveRunStart } from "../agent/runClock"; +import { useConversationStore } from "./conversationStore"; + +// The run clock as the store drives it from the live events: the user's send, the busy +// edges of `session_state`, each `turn_result` (whose store id the footer is keyed by) +// and the background-task snapshots the event router forwards with `noteTask`. + +const store = () => useConversationStore.getState(); +const entry = (s: string) => useConversationStore.getState().sessions[s]; + +function setBusy(s: string, busy: boolean) { + store().applyState(s, { ...entry(s).state, busy }); +} + +function result(durationApiMs: number | null): ConversationItem { + return { + kind: "turn_result", + subtype: "success", + is_error: false, + result: null, + api_error_status: null, + total_cost_usd: null, + num_turns: 1, + duration_ms: 1, + duration_api_ms: durationApiMs, + ttft_ms: null, + }; +} + +/** The store id of the latest `turn_result` in the timeline. */ +function lastResultId(s: string): string { + const t = entry(s).timeline.filter((e) => e.kind === "turn_result"); + return t[t.length - 1].id; +} + +describe("run clock in the conversation store", () => { + beforeEach(() => { + useConversationStore.setState({ sessions: {} }); + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("times a prompt from the Enter across the follow-up turns its background work triggers", () => { + store().addUserTurn("c1", "go", false); + vi.setSystemTime(100); + setBusy("c1", true); + store().noteTask("c1", "bash", true); + vi.setSystemTime(6000); + store().applyItem("c1", result(4000)); + setBusy("c1", false); + const main = lastResultId("c1"); + expect(runFooterFor(entry("c1").runClock, main)).toMatchObject({ + mainMs: 6000, + backgroundSince: 0, + backgroundCount: 1, + }); + + // Progress ticks of a running task leave the store untouched. + const before = entry("c1"); + store().noteTask("c1", "bash", true); + expect(entry("c1")).toBe(before); + + // The Bash reports back: the CLI's follow-up turn keeps the Enter as its start. + vi.setSystemTime(40_000); + store().noteTask("c1", "bash", false); + vi.setSystemTime(41_000); + setBusy("c1", true); + expect(liveRunStart(entry("c1").runClock)).toBe(0); + vi.setSystemTime(43_000); + store().applyItem("c1", result(900)); + setBusy("c1", false); + + const followUp = lastResultId("c1"); + expect(runFooterFor(entry("c1").runClock, main)).toBe("hidden"); + expect(runFooterFor(entry("c1").runClock, followUp)).toMatchObject({ + mainMs: 6000, + totalMs: 43_000, + extended: true, + modelMs: 4900, + }); + }); + + it("keeps a message sent mid-turn in the run in flight", () => { + store().addUserTurn("c2", "first", false); + setBusy("c2", true); + vi.setSystemTime(5000); + store().addUserTurn("c2", "and also", true); + expect(entry("c2").runClock.runs).toHaveLength(1); + }); + + it("ends a run whose process ended", () => { + store().addUserTurn("c3", "go", false); + setBusy("c3", true); + store().noteTask("c3", "bash", true); + store().applyItem("c3", result(100)); + setBusy("c3", false); + vi.setSystemTime(9000); + store().applyState("c3", { ...entry("c3").state, ended: true }); + expect(runFooterFor(entry("c3").runClock, lastResultId("c3"))).toMatchObject({ + totalMs: 9000, + backgroundSince: null, + }); + }); + + it("ignores a task snapshot for a conversation it does not hold", () => { + const before = useConversationStore.getState(); + store().noteTask("nope", "bash", true); + expect(useConversationStore.getState()).toBe(before); + }); +}); diff --git a/src/store/types.ts b/src/store/types.ts index 59b495e0..f5a3236f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -9,6 +9,7 @@ import type { PermissionRequestPayload, SessionStatePayload, } from "../ipc/client"; +import type { RunClock } from "../agent/runClock"; export type { ConversationItem, @@ -257,11 +258,18 @@ export interface SessionEntry { /** * Wall-clock start (`Date.now()`) of the turn currently in flight, or `null` when no * turn is running. Stamped when `state.busy` goes false→true and cleared on true→false - * (and on `clearState`). Drives the LIVE elapsed counter in the working indicator (shown - * once a turn runs past a threshold, like the CLI). NOT the finished turn's duration — that - * is `TurnResultMeta.durationMs`, measured by the binary and delivered in `turn_result`. + * (and on `clearState`). A TURN clock: a follow-up turn the CLI starts on its own + * restamps it, so the counters the user reads come from {@link runClock} instead; this + * is their fallback when no run is known. */ turnStartedAt: number | null; + /** + * The RUN clock (see `agent/runClock.ts`): one run per prompt, from the user's Enter to + * the end of the last background work it launched, spanning the follow-up turns that + * work triggers. Drives the working indicator's live counter, the single footer under a + * run and the sidebar row's time. Front-measured, live-only (resets with the entry). + */ + runClock: RunClock; /** * Start of the LAST turn, kept after that turn ends (unlike {@link turnStartedAt}, which * is cleared). With {@link lastTurnEndedAt} it gives the duration a settled sidebar row From 13df6e5e67acdab5bb6ed4c0110cef49d1dfd87a Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Mon, 28 Sep 2026 12:42:45 +0200 Subject: [PATCH 02/24] =?UTF-8?q?feat(titlebar):=20"=C3=89pur=C3=A9"=20tit?= =?UTF-8?q?le=20bar=20=E2=80=94=20underlined=20tabs,=20bare=20glyphs,=20on?= =?UTF-8?q?e=20system=20tray=20(CRM=2029ff1ebd)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restyle of the existing title bar, same controls as before: - The bar sits flush with the app (same ground, a hairline under it). - View tabs are plain text; ONE underline sits under the current tab and slides to the next one (useTabUnderline in kit.tsx, observer-driven, no per-render layout). - Title-bar buttons lose their frame: open = lit glyph + a dot underneath that grows in and out. Scoped to .wf-titlebar — the sidebar and Flight Deck keep the framed look. - Voice session / mic / notification sound / Caffeinate share one pill (SystemTray), each still a one-click toggle, lit white when on and grey when off. Caffeinate gets a chevron to pick Light / Hard. Replaces VoiceToggle, SoundToggle and CaffeinateToggle. - The stream / worktree / TOSSE task chips drop their frame when they sit in the bar. - New Motion pref "Animate the title bar" (titleBarAnimations, ON by default); the OS reduce-motion setting always wins. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 24 +-- src/features/notifications/SoundToggle.tsx | 33 ----- src/features/power/CaffeinateToggle.tsx | 38 ----- src/features/settings/SettingsPanel.tsx | 16 ++ src/features/settings/settingsSearch.ts | 1 + src/features/tosse/TosseTaskChip.module.css | 11 ++ src/store/display.ts | 9 ++ src/ui/SystemTray.tsx | 153 ++++++++++++++++++++ src/ui/conductor-wirekit.css | 122 ++++++++++++++-- src/ui/kit.tsx | 71 +++++++-- src/voice/VoiceToggle.tsx | 73 ---------- 11 files changed, 369 insertions(+), 182 deletions(-) delete mode 100644 src/features/notifications/SoundToggle.tsx delete mode 100644 src/features/power/CaffeinateToggle.tsx create mode 100644 src/ui/SystemTray.tsx delete mode 100644 src/voice/VoiceToggle.tsx diff --git a/src/App.tsx b/src/App.tsx index fe153043..939b8f90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,6 @@ import { EditorToggle } from "./features/editor/EditorToggle"; import { FlightDeck } from "./features/flightdeck/FlightDeck"; import { FlightDeckReplyModal } from "./features/flightdeck/FlightDeckReplyModal"; import { useFlightdeckModal } from "./features/flightdeck/flightdeckModalStore"; -import { SoundToggle } from "./features/notifications/SoundToggle"; -import { CaffeinateToggle } from "./features/power/CaffeinateToggle"; import { CaffeinateHost } from "./features/power/CaffeinateHost"; import { MachineHealthHost } from "./features/machines/MachineHealthHost"; import { AutoAccountSwitchHost } from "./features/settings/AutoAccountSwitchHost"; @@ -44,7 +42,6 @@ import { useThreadJump } from "./store/threadJump"; import { useGlobalSessionEvents } from "./ipc/useGlobalSessionEvents"; import { AppControlHost } from "./agent/AppControlHost"; import { VoiceHost } from "./voice/VoiceHost"; -import { VoiceMicToggle, VoiceModeToggle } from "./voice/VoiceToggle"; import { startUpdaterAutoCheck } from "./store/updater"; import { startClaudeCliAutoCheck } from "./store/claudeCliUpdate"; import { initNotifications } from "./notifications/notify"; @@ -59,7 +56,8 @@ import { import { useDisplay } from "./store/display"; import { useNotifications } from "./store/notifications"; import { useSettingsUi } from "./store/settingsUi"; -import { NavBtn, TosseCrmMark, Win } from "./ui/kit"; +import { NavBtn, Win } from "./ui/kit"; +import { SystemTray } from "./ui/SystemTray"; import { runAppAction } from "./ui/appActions"; import { ACTION_BINDINGS, @@ -107,6 +105,7 @@ export default function App() { // With the conversation side panel on, the header carries ACTIONS only: the task chip, // the worktree indicator and the stream control move into the panel. const sidePanel = useDisplay((s) => s.conversationSidePanel); + const titleBarMotion = useDisplay((s) => s.titleBarAnimations); // The IDE tab is conditional too, on its display preference alone (Settings → General → // Display). Off → no tab, no "Open in IDE" entry points, and the view is never mounted. @@ -271,6 +270,7 @@ export default function App() { return ( changeView("conversation")} /> } label="TOSSE" on={view === "tosse"} title="TOSSE tasks (⌘3)" @@ -312,7 +309,6 @@ export default function App() { {/* Last, so it keeps ⌘4 whether or not the conditional TOSSE tab is showing. */} {ideAvailable ? ( - {/* Always visible (both views): mute/unmute the notification chime on the - spot, without opening Settings. Also bound to ⌘⇧M. */} - {/* Voice agent: arm/disarm the session, then open/close the mic within - it. Both render nothing until an OpenAI key is configured. */} - - - - {/* Always visible (both views): arm/disarm Caffeinate (keep the Mac awake). */} - + {/* Always visible (every view): the app-wide switches — voice session + mic, + notification sound (also ⌘⇧M), Caffeinate — folded into one tray. */} + {view === "conversation" && activeRepo ? ( <> {/* App-wide toggles | this conversation's actions. */} diff --git a/src/features/notifications/SoundToggle.tsx b/src/features/notifications/SoundToggle.tsx deleted file mode 100644 index 3bf42432..00000000 --- a/src/features/notifications/SoundToggle.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Ico } from "../../ui/kit"; -import { useNotifications } from "../../store/notifications"; - -/** - * Always-visible title-bar toggle for the notification sound — the one channel - * the user reaches for most (mute the chime on the spot, without diving into - * Settings). Mirrors EditorToggle/TerminalToggle/GitToggle (`wf-icon-btn`, goes - * `.on` when the sound is enabled) and drives the SAME persisted pref as the - * Settings → Notifications switch, so both stay in sync. Its ⌘⇧M shortcut lives - * in App.tsx (see `isSoundToggleChord`). - */ -export function SoundToggle() { - const sound = useNotifications((s) => s.sound); - const toggleSound = useNotifications((s) => s.toggleSound); - - return ( - - ); -} diff --git a/src/features/power/CaffeinateToggle.tsx b/src/features/power/CaffeinateToggle.tsx deleted file mode 100644 index 36bdc30b..00000000 --- a/src/features/power/CaffeinateToggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Ico } from "../../ui/kit"; -import { useCaffeinate } from "../../store/caffeinate"; - -/** - * Always-visible title-bar toggle for "Caffeinate" — keep the Mac awake while agents - * work. Mirrors SoundToggle/EditorToggle/TerminalToggle (`wf-icon-btn`, goes `.on` when - * armed) and drives the SAME persisted pref as the Settings → Caffeinate switch, so both - * stay in sync. - * - * The button only ARMS the feature; whether the Mac is actually held awake right now - * depends on the mode (Settings): in Light it follows fleet activity, in Hard it's - * permanent. CaffeinateHost turns "armed + mode + activity" into the real assertion. - */ -export function CaffeinateToggle() { - const enabled = useCaffeinate((s) => s.enabled); - const mode = useCaffeinate((s) => s.mode); - const toggle = useCaffeinate((s) => s.toggleEnabled); - - const title = enabled - ? mode === "hard" - ? "Keeping the Mac awake (Hard) — click to let it sleep" - : "Keeping the Mac awake while agents work (Light) — click to let it sleep" - : "Let the Mac sleep — click to keep it awake"; - - return ( - - ); -} diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index 5ed32755..542556c3 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -930,6 +930,7 @@ function MotionPrefs() { const flightdeckModalZoom = useDisplay((s) => s.flightdeckModalZoom); const panelAnimations = useDisplay((s) => s.panelAnimations); const conversationAnimations = useDisplay((s) => s.conversationAnimations); + const titleBarAnimations = useDisplay((s) => s.titleBarAnimations); const set = useDisplay((s) => s.set); return ( <> @@ -984,6 +985,21 @@ function MotionPrefs() { onChange={(v) => set({ conversationAnimations: v })} label="Animate work folding and unfolding" /> + + The underline under the current view slides to the tab you pick, + a panel button's open dot grows in and out, and the system + switches give under the click. On by default (under a fifth of a + second). Off → the bar changes state in one frame. Your system's{" "} + "reduce motion" setting always wins over this. + + } + checked={titleBarAnimations} + onChange={(v) => set({ titleBarAnimations: v })} + label="Animate the title bar" + /> ); diff --git a/src/features/settings/settingsSearch.ts b/src/features/settings/settingsSearch.ts index 156b2158..2d9b36ed 100644 --- a/src/features/settings/settingsSearch.ts +++ b/src/features/settings/settingsSearch.ts @@ -54,6 +54,7 @@ export const SETTINGS_INDEX: readonly SettingEntry[] = [ { title: "Zoom when opening a card", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion modal flight deck display affichage mouvement" }, { title: "Slide side panels open", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion panel editor terminal display affichage mouvement" }, { title: "Animate the conversation", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion thread display affichage mouvement" }, + { title: "Animate the title bar", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion tabs onglets underline header titlebar barre buttons boutons display affichage mouvement" }, { title: "Message controls", section: "display", sub: "thread", group: "Thread", keywords: "rewind fork hover controls rembobiner display affichage" }, { title: "Clickable filename on Read/Write rows", section: "display", sub: "thread", group: "Thread", keywords: "file mention path link chemin cliquable display affichage" }, { title: "Show hosted artifacts in Flight Deck", section: "display", sub: "thread", group: "Thread", keywords: "artifact artefact claude design canvas claude.ai webview browser navigateur panel panneau" }, diff --git a/src/features/tosse/TosseTaskChip.module.css b/src/features/tosse/TosseTaskChip.module.css index 576cbe2e..026724eb 100644 --- a/src/features/tosse/TosseTaskChip.module.css +++ b/src/features/tosse/TosseTaskChip.module.css @@ -29,6 +29,17 @@ border-color: var(--wf-tx-xlo); color: var(--wf-tx-hi); } +/* In the title bar the chip drops its frame like the controls around it (a surface only on + hover); the Flight Deck card keeps the outlined version. */ +:global(.wf-titlebar) .chip { + height: 26px; + border-color: transparent; + border-radius: 8px; +} +:global(.wf-titlebar) .clickable:hover { + background: var(--wf-panel); + border-color: transparent; +} /* On a Flight Deck card the chip sits among the other marks: the title would not fit and the mark + status dot already answer "which task". */ .compact { diff --git a/src/store/display.ts b/src/store/display.ts index ef975745..dde87285 100644 --- a/src/store/display.ts +++ b/src/store/display.ts @@ -148,6 +148,13 @@ export interface DisplayPrefs { * necessarily the person who wants panels to stop sliding. */ conversationAnimations: boolean; + /** Animate the title bar: the current view's underline SLIDES to the tab you pick instead + * of jumping there, a panel button's "open" dot grows in and out, and the system switches + * give under the click. ON by default (~0.18 s for the slide, ~0.14 s for the rest). Off → + * the bar changes state in one frame. The OS "reduce motion" setting overrides this + * whichever way it is set (CSS media query). Read by App → {@link Win}'s `motion` flag. */ + titleBarAnimations: boolean; + /** Show the hover controls on conversation messages — "resume from here" (rewind * the conversation in place) and "fork" (branch a new conversation at this message), * offered on both the user's and Claude's messages. ON by default. Off → messages have no @@ -347,6 +354,7 @@ const DEFAULTS: DisplayPrefs = { flightdeckModalZoom: true, panelAnimations: true, conversationAnimations: true, + titleBarAnimations: true, messageControls: true, clickableFileMentions: true, tosseToolCards: true, @@ -426,6 +434,7 @@ export const useDisplay = create((set) => ({ flightdeckModalZoom: patch.flightdeckModalZoom ?? s.flightdeckModalZoom, panelAnimations: patch.panelAnimations ?? s.panelAnimations, conversationAnimations: patch.conversationAnimations ?? s.conversationAnimations, + titleBarAnimations: patch.titleBarAnimations ?? s.titleBarAnimations, messageControls: patch.messageControls ?? s.messageControls, clickableFileMentions: patch.clickableFileMentions ?? s.clickableFileMentions, tosseToolCards: patch.tosseToolCards ?? s.tosseToolCards, diff --git a/src/ui/SystemTray.tsx b/src/ui/SystemTray.tsx new file mode 100644 index 00000000..edceaced --- /dev/null +++ b/src/ui/SystemTray.tsx @@ -0,0 +1,153 @@ +import { Ico, Menu, MenuItem, MenuLabel } from "./kit"; +import { useCaffeinate, type CaffeinateMode } from "../store/caffeinate"; +import { useNotifications } from "../store/notifications"; +import { toggleMic, toggleVoiceMode } from "../voice/realtime"; +import { describePtt } from "../voice/pttShortcut"; +import { useVoicePrefs } from "../voice/voicePrefs"; +import { useVoiceStore } from "../voice/voiceStore"; + +const CAFFEINATE_MODE_LABEL: Record = { + light: "Light — follow the agents", + hard: "Hard — always awake", +}; + +/** + * Title-bar tray for the app-wide switches: the voice session and its microphone, the + * notification sound, and Caffeinate (keep the Mac awake). One pill of bare glyphs, each a + * ONE-click toggle that lights up while on; Caffeinate adds a small chevron to pick its mode. + * + * Every button drives the SAME store/action as Settings, so both stay in sync, and the ⌘⇧M + * chord (App.tsx) and the push-to-talk key (VoiceHost) keep working untouched. The voice + * buttons only exist while an OpenAI key is configured, and the mic only while the voice + * session is armed: the feature is strictly optional and must never dangle dead controls. + * Caffeinate only ARMS here; whether the Mac is actually held awake depends on its mode + * (Light follows fleet activity, Hard is permanent) — CaffeinateHost turns "armed + mode + + * activity" into the real assertion. + */ +export function SystemTray() { + const voiceConfigured = useVoiceStore((s) => s.configured) === true; + const voicePhase = useVoiceStore((s) => s.phase); + const voiceMode = useVoiceStore((s) => s.mode); + const voiceError = useVoiceStore((s) => s.error); + const micOpen = useVoiceStore((s) => s.micOpen); + const pttShortcut = useVoicePrefs((s) => s.pttShortcut); + const sound = useNotifications((s) => s.sound); + const toggleSound = useNotifications((s) => s.toggleSound); + const caffeinate = useCaffeinate((s) => s.enabled); + const caffeinateMode = useCaffeinate((s) => s.mode); + const setCaffeinate = useCaffeinate((s) => s.set); + const toggleCaffeinate = useCaffeinate((s) => s.toggleEnabled); + + const voiceFailed = voicePhase === "error" && !!voiceError; + const pttKey = describePtt(pttShortcut); + const modeName = caffeinateMode === "hard" ? "Hard" : "Light"; + + return ( +
+ {voiceConfigured ? ( + void toggleVoiceMode()} + /> + ) : null} + {voiceConfigured && voiceMode ? ( + void toggleMic()} + /> + ) : null} + + + + + + } + > + Caffeinate mode + {(["light", "hard"] as const).map((m) => ( + setCaffeinate({ mode: m })}> + {CAFFEINATE_MODE_LABEL[m]} + + ))} + +
+ ); +} + +/** One switch in the pill: a bare glyph that lights up while on (red if it failed). */ +function TrayButton({ + icon, + on, + err, + label, + title, + onClick, +}: { + icon: string; + on: boolean; + err?: boolean; + label: string; + title: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/ui/conductor-wirekit.css b/src/ui/conductor-wirekit.css index 34bd7fd8..ad49554b 100644 --- a/src/ui/conductor-wirekit.css +++ b/src/ui/conductor-wirekit.css @@ -72,12 +72,14 @@ box-shadow: 0 0 0 1px var(--wf-line); -webkit-font-smoothing: antialiased; } +/* The title bar sits flush with the app ("Épuré"): same ground as the body, a hairline + under it, no framed controls — the tabs are underlined text, the buttons bare glyphs. */ .wf-titlebar { height: 38px; flex: 0 0 38px; - display: flex; align-items: center; gap: 14px; - padding: 0 14px; - background: var(--wf-bg-2); - border-bottom: 1px solid var(--wf-line); + display: flex; align-items: center; gap: 20px; + padding: 0 16px; + background: var(--wf-bg); + border-bottom: 1px solid color-mix(in srgb, var(--wf-line) 70%, var(--wf-bg)); user-select: none; } .wf-traffic { display: flex; gap: 8px; } @@ -94,27 +96,124 @@ also the FIRST thing to give way (`flex: 0 1 auto` here, `0 0 auto` on the nav and the controls): the title is the one part already shown elsewhere, in the sidebar. */ .wf-title { - font-size: 12px; color: var(--wf-tx-lo); letter-spacing: .2px; + font-size: 12.5px; font-weight: 500; color: var(--wf-tx-hi); flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.wf-tbnav { flex: 0 0 auto; display: flex; gap: 2px; margin-left: 4px; background: var(--wf-panel); padding: 3px; border-radius: 8px; border: 1px solid var(--wf-line); } +/* View tabs: plain text, the current one underlined on the bar's bottom edge. The nav + stretches to the bar's full height so that underline can sit ON the hairline (bottom:-1px). + Every tab carries the same weight — a bolder current tab would change its width and nudge + its neighbours on each switch; colour alone tells them apart. The thin rule after the nav + separates the tabs from the window title. */ +.wf-tbnav { + flex: 0 0 auto; align-self: stretch; position: relative; + display: flex; align-items: stretch; gap: 18px; +} +.wf-tbnav::after { + content: ""; position: absolute; right: -10px; top: 50%; + width: 1px; height: 16px; margin-top: -8px; background: var(--wf-line); +} .wf-tbnav button { - font: inherit; font-size: 11.5px; color: var(--wf-tx-lo); - background: transparent; border: 0; padding: 4px 11px; border-radius: 6px; cursor: default; + font: inherit; font-size: 12.5px; font-weight: 500; color: var(--wf-tx-lo); + position: relative; background: transparent; border: 0; padding: 0 2px; cursor: pointer; display: flex; align-items: center; gap: 6px; + transition: color .15s ease; +} +.wf-tbnav button:hover { color: var(--wf-tx); } +.wf-tbnav button[data-on] { color: var(--wf-tx-hi); } +/* ONE underline for the whole nav, placed under the current tab by `useTabUnderline` (kit.tsx) + — a single element is what lets it slide from tab to tab (see the motion block below). */ +.wf-tbnav-line { + position: absolute; left: 0; bottom: -1px; width: 0; height: 2px; + border-radius: 2px; background: var(--wf-tx-hi); pointer-events: none; opacity: 0; } -.wf-tbnav button[data-on] { background: var(--wf-panel-3); color: var(--wf-tx-hi); } .wf-tbnav .wf-badge { font-size: 9.5px; padding: 0 5px; } /* The controls are allowed to SHRINK, but their buttons are not: `flex: 0 1 auto` + `min-width: 0` is what passes the pressure down to the one child with variable-width content (the TOSSE task chip, which truncates), instead of the whole row spilling past the window. A fixed-size button has no give and keeps its hit area — half a button would be worse than a shorter caption. Order of sacrifice: the title, then that chip. */ -.wf-tbright { flex: 0 1 auto; margin-left: auto; display: flex; align-items: center; gap: 10px; min-width: 0; } +.wf-tbright { flex: 0 1 auto; margin-left: auto; display: flex; align-items: center; gap: 4px; min-width: 0; } + +/* Title-bar buttons are bare glyphs (the `.wf-icon-btn` frame is dropped HERE only — the + sidebar and the Flight Deck keep theirs). Open = the glyph lights up and a dot sits under + it, like the Dock marks a running app. */ +.wf-titlebar .wf-icon-btn { + position: relative; width: 30px; height: 30px; + border-color: transparent; background: transparent; color: var(--wf-tx-lo); + border-radius: 8px; +} +.wf-titlebar .wf-icon-btn .wf-ico { width: 15px; height: 15px; } +.wf-titlebar .wf-icon-btn:hover:not(:disabled):not([aria-disabled]) { + background: var(--wf-panel); border-color: transparent; color: var(--wf-tx-hi); + transform: none; +} +.wf-titlebar .wf-icon-btn:active:not(:disabled):not([aria-disabled]) { + background: var(--wf-panel-2); transform: scale(.94); +} +.wf-titlebar .wf-icon-btn[data-on] { background: transparent; border-color: transparent; color: var(--wf-tx-hi); } +/* The dot is always there, shrunk to nothing while closed, so it can grow in and out. */ +.wf-titlebar .wf-icon-btn::after { + content: ""; position: absolute; left: 50%; bottom: 1px; + width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--wf-tx-hi); + transform: scale(0); opacity: 0; +} +.wf-titlebar .wf-icon-btn[data-on]::after { transform: scale(1); opacity: 1; } +/* The chips that join the bar when the conversation panel is off (stream control, worktree) + lose their frame the same way — a surface only on hover / while their menu is open. */ +.wf-titlebar .wf-streamctl { border-color: transparent; background: transparent; } +.wf-titlebar .wf-streamctl:hover, +.wf-titlebar .wf-streamctl[data-open] { background: var(--wf-panel); border-color: transparent; } + +/* ---- title-bar system tray --------------------------------- + The app-wide switches (voice session + mic, notification sound, Caffeinate) share one + pill. Each glyph is a one-click toggle that lights up while on; Caffeinate's small chevron + opens its mode menu. Qualified with `.wf-tray` so the icon sizes beat `.wf-ico.sm`, which + is declared further down. */ +.wf-tray { + flex: none; height: 28px; margin-right: 8px; padding: 0 2px; box-sizing: border-box; + display: inline-flex; align-items: center; gap: 1px; + border-radius: 999px; border: 1px solid var(--wf-line); background: var(--wf-bg-2); +} +.wf-tray-b, .wf-tray-more { + font: inherit; cursor: pointer; height: 22px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + border: 0; border-radius: 999px; background: transparent; color: var(--wf-tx-xlo); + transition: background .15s ease, color .15s ease; +} +.wf-tray-b { width: 26px; } +.wf-tray .wf-tray-b .wf-ico { width: 13px; height: 13px; } +/* On/off is the glyph's colour alone — lit white vs dim grey; the surface is hover-only. */ +.wf-tray-b[data-on] { color: var(--wf-tx-hi); } +.wf-tray-b:hover, .wf-tray-more:hover, .wf-tray-more[data-open] { background: var(--wf-panel-3); color: var(--wf-tx-hi); } +.wf-tray-b:active { background: var(--wf-panel-2); } +.wf-tray-b[data-err] { color: var(--wf-err); } +/* Caffeinate's mode chevron: a narrow tab tucked against the coffee glyph. */ +.wf-tray-more { width: 14px; margin-left: -1px; color: var(--wf-tx-xlo); } +.wf-tray .wf-tray-more .wf-ico { width: 10px; height: 10px; transition: transform .15s ease; } +.wf-tray-more[data-open] .wf-ico { transform: rotate(180deg); } + +/* ---- title-bar motion --------------------------------------- + Short and quiet, only where it explains a change: the underline slides to the tab just + picked, a panel's "open" dot grows in and out, a system switch gives under the click. + Gated twice: the `titleBarAnimations` pref (the bar's `data-motion`) and the OS "reduce + motion" setting, which always wins. The underline only travels once it has been placed + (`data-ready`), so it does not sweep in from the left edge on launch. */ +@media (prefers-reduced-motion: no-preference) { + .wf-titlebar[data-motion] .wf-tbnav-line[data-ready] { + transition: transform .18s cubic-bezier(0.32, 0.72, 0, 1), width .18s cubic-bezier(0.32, 0.72, 0, 1); + } + .wf-titlebar[data-motion] .wf-icon-btn::after { + transition: transform .14s cubic-bezier(0.32, 0.72, 0, 1), opacity .1s ease; + } + .wf-titlebar[data-motion] .wf-tray-b { + transition: background .15s ease, color .15s ease, transform .1s ease; + } + .wf-titlebar[data-motion] .wf-tray-b:active { transform: scale(.88); } +} .wf-body { flex: 1; display: flex; min-height: 0; } .wf-col { display: flex; flex-direction: column; min-height: 0; min-width: 0; } @@ -232,7 +331,7 @@ .wf-btn:disabled { opacity: .4; cursor: default; } /* A thin vertical rule between groups of title-bar controls (app-wide toggles | conversation actions | the conversation panel toggle). */ -.wf-tb-sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--wf-line); } +.wf-tb-sep { flex: none; width: 1px; height: 16px; margin: 0 6px; background: color-mix(in srgb, var(--wf-line) 80%, var(--wf-bg)); } .wf-icon-btn { width: 28px; height: 28px; display: inline-flex; align-items:center; justify-content:center; border-radius: var(--wf-r); border: 1px solid var(--wf-line); background: var(--wf-panel); color: var(--wf-tx); @@ -475,6 +574,7 @@ .wf-eff-fill.ultra, .wf-eff-thumb.ultra { animation:none; } .wf-icon-btn { transition:none; } .wf-icon-btn:hover:not(:disabled), .wf-icon-btn:active:not(:disabled) { transform:none; } + .wf-tbnav button, .wf-tray-b, .wf-tray-more, .wf-tray-more .wf-ico { transition:none; } } /* ---- context ring ------------------------------------------ */ diff --git a/src/ui/kit.tsx b/src/ui/kit.tsx index 113e9c5e..759968d0 100644 --- a/src/ui/kit.tsx +++ b/src/ui/kit.tsx @@ -10,6 +10,7 @@ import { type CSSProperties, type ReactElement, type ReactNode, + type RefObject, } from "react"; import { createPortal } from "react-dom"; @@ -242,11 +243,56 @@ export function UserMark({ className }: { className?: string }) { ); } +/** + * Keeps the title bar's ONE underline under the current tab (the nav child carrying + * `data-on`). A single element that moves — rather than an underline per tab — is what lets it + * SLIDE from the old tab to the new one; the transition itself is CSS, gated on the bar's + * `data-motion`. Driven by observers rather than by React renders: the App re-renders the bar + * on many unrelated store changes, and measuring on each would force a layout every time. The + * MutationObserver catches a tab switch (`data-on` moving) and a tab appearing (TOSSE once + * signed in); the ResizeObserver catches widths changing (fonts landing). The line only gets + * `data-ready` — and with it the transition — after its first placement, so it does not sweep + * in from the left edge on launch. + */ +function useTabUnderline( + navRef: RefObject, + lineRef: RefObject, + enabled: boolean, +) { + useLayoutEffect(() => { + const nav = navRef.current; + const line = lineRef.current; + if (!enabled || !nav || !line) return; + const place = () => { + const tab = nav.querySelector(":scope > [data-on]"); + if (!tab) { + line.style.opacity = "0"; + return; + } + line.style.opacity = "1"; + line.style.width = `${tab.offsetWidth}px`; + line.style.transform = `translateX(${tab.offsetLeft}px)`; + }; + place(); + const raf = requestAnimationFrame(() => line.setAttribute("data-ready", "")); + const mo = new MutationObserver(place); + mo.observe(nav, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-on"] }); + const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(place); + ro?.observe(nav); + return () => { + cancelAnimationFrame(raf); + mo.disconnect(); + ro?.disconnect(); + }; + }, [navRef, lineRef, enabled]); +} + export function Win({ title, nav, right, banner, + motion = false, children, }: { title?: ReactNode; @@ -254,12 +300,23 @@ export function Win({ right?: ReactNode; /** Full-width strip between the title bar and the body (e.g. update banner). */ banner?: ReactNode; + /** Animate the title bar (the tab underline slides, button states ease) — the + * `titleBarAnimations` display pref. The OS "reduce motion" setting still wins, in CSS. */ + motion?: boolean; children: ReactNode; }) { + const navRef = useRef(null); + const underlineRef = useRef(null); + useTabUnderline(navRef, underlineRef, !!nav); return (
-
- {nav ?
{nav}
: null} +
+ {nav ? ( +
+ {nav} +
+ ) : null} {/* `title` attribute when the caption is a plain string: it is truncated with an ellipsis, so the full name has to stay readable somewhere. */} {title ? ( @@ -275,19 +332,14 @@ export function Win({ ); } +/** A title-bar view tab: plain text, underlined while current (see `.wf-tbnav`). */ export function NavBtn({ - icon, - glyph, label, on, badge, title, onClick, }: { - icon?: string; - /** A ready-made mark, for a tab whose identity isn't one of the `WF_PATHS` glyphs — the - * TOSSE tab wears the CRM's own brand mark. Takes precedence over `icon`. */ - glyph?: React.ReactNode; label: string; on?: boolean; badge?: number | null; @@ -295,8 +347,7 @@ export function NavBtn({ onClick?: () => void; }) { return ( - diff --git a/src/voice/VoiceToggle.tsx b/src/voice/VoiceToggle.tsx deleted file mode 100644 index fae883d1..00000000 --- a/src/voice/VoiceToggle.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// Title-bar controls for the voice agent, mirroring SoundToggle's `wf-icon-btn` -// pattern. TWO buttons, matching the two-layer model: -// - VoiceModeToggle (headset): arm/disarm the "session vocale". Armed = fleet -// events are announced aloud and the mic opens for replies. -// - VoiceMicToggle (mic): open/close the microphone — only rendered while the -// mode is armed (its keyboard twin, the customizable push-to-talk key, also -// arms first when pressed from cold). -// Both hidden entirely while no OpenAI key is configured — the feature is -// strictly optional and must not dangle dead buttons. Visibility reads the -// SHARED voiceStore mirror of the Rust status (seeded by VoiceHost, updated -// live by the Settings card) — no cache to go stale. -import { Ico } from "../ui/kit"; -import { toggleMic, toggleVoiceMode } from "./realtime"; -import { describePtt } from "./pttShortcut"; -import { useVoicePrefs } from "./voicePrefs"; -import { useVoiceStore } from "./voiceStore"; - -export function VoiceModeToggle() { - const phase = useVoiceStore((s) => s.phase); - const mode = useVoiceStore((s) => s.mode); - const error = useVoiceStore((s) => s.error); - const configured = useVoiceStore((s) => s.configured); - if (configured !== true) return null; - - const title = - phase === "error" && error - ? `Voice session error — click to retry — ${error}` - : phase === "connecting" - ? "Voice session connecting…" - : mode - ? "Voice session armed — fleet events are spoken; click to disarm" - : "Arm the voice session (announce fleet events, talk to the fleet)"; - return ( - - ); -} - -export function VoiceMicToggle() { - const phase = useVoiceStore((s) => s.phase); - const mode = useVoiceStore((s) => s.mode); - const micOpen = useVoiceStore((s) => s.micOpen); - const configured = useVoiceStore((s) => s.configured); - const shortcut = useVoicePrefs((s) => s.pttShortcut); - if (configured !== true || !mode) return null; - - const key = describePtt(shortcut); - const title = micOpen - ? `Microphone open${phase === "speaking" ? " (agent speaking)" : ""} — click to close (${key})` - : `Microphone closed — click to talk (${key})`; - return ( - - ); -} From b731266e0f2167af9db3ce1c02560dfcd20cf31a Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Sun, 20 Sep 2026 18:52:44 +0200 Subject: [PATCH 03/24] feat(conversation): resizable side panel + fix the artifact icon, reach older versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side panel now drags to a width of its own (286–560px, persisted in `tosse:editor` like `treeWidth`). The splitter travels inside the animated slot so it slides in with the panel; the rendered width is capped by the room left beside the conversation and the editor/terminal region, so a wide stored panel never pushes the composer under its floor — it springs back when the room returns. The docking test now measures the panel's FLOOR rather than a fixed 340, so a narrower window docks a narrow panel instead of floating a wide one. The artifact tile was empty because the wire changed under us: the `Artifact` tool no longer sends `favicon` (an emoji) but `icon` (a WORD — "sparkle", "report", "code"). Checked against the transcripts on disk: `favicon` alone up to 2026-09-14, both during a short overlap, `icon` alone from 2026-09-20. Both are read now and a word is resolved back to a pictograph in `artifactIcon.ts`. Emoji rather than a kit glyph because the vocabulary is open — a 70-glyph UI set has nothing for "recipe", "calendar" or "map", so most artifacts would land back on the generic fallback — and a conversation mixing both wires renders as one list. An artifact's version history is now reachable: the "vN" badge expands into a row per publish (its real number, from the ack's "(Version N)" suffix, else its position), and a row scrolls the thread to the card that published it. That card is the ONLY place an older version's contents still exist — claude.ai serves the latest, and each republish overwrites the same temp file — so nothing fabricates a per-version link. Opening an artifact preview now puts the side panel away and brings it back when the preview closes (pref `artifactHidesSidePanel`, ON). Transient: `convPanelOpen` keeps the user's own answer, so an artifact never OPENS a panel that was closed, and asking for the panel outranks the preview. Also: the composer popover passes `multiFile` through (a page published with sibling files was previewed locally and rendered stripped of its stylesheet), and shows real version numbers and failed publishes. New `?demo=artifacts` scenario — two artifacts, one republished twice, both wires — for visual checks. Verified: tsc clean, 2285/2285 vitest, `pnpm build` green, and all four behaviours driven end to end in the browser mock (resize + both clamps, the conversation floor with the editor open, the version jump flashing the right card, the panel hiding and returning — docked and floating). Co-Authored-By: Claude Opus 5 --- src/features/conversation/ArtifactCard.tsx | 9 +- src/features/conversation/ArtifactsChip.tsx | 12 +- .../conversation/ConductorConversation.tsx | 46 ++- .../ConversationSidePanel.module.css | 134 ++++++++- .../conversation/ConversationSidePanel.tsx | 123 ++++++-- .../conversation/artifactIcon.test.ts | 55 ++++ src/features/conversation/artifactIcon.ts | 270 ++++++++++++++++++ src/features/conversation/artifactOpen.ts | 4 +- src/features/conversation/artifacts.test.ts | 146 ++++++++++ src/features/conversation/artifacts.ts | 49 +++- .../conversation/sidePanelLayout.test.ts | 65 ++++- src/features/conversation/sidePanelLayout.ts | 67 ++++- .../conversation/useThreadJumpTarget.ts | 9 +- src/features/editor/editorStore.test.ts | 102 ++++++- src/features/editor/editorStore.ts | 69 +++-- src/features/settings/SettingsPanel.tsx | 15 + src/features/settings/settingsSearch.ts | 1 + src/ipc/mock/mockBindings.ts | 1 + src/ipc/mock/scenario.ts | 91 ++++++ src/store/display.ts | 10 + src/store/threadJump.ts | 7 +- 21 files changed, 1209 insertions(+), 76 deletions(-) create mode 100644 src/features/conversation/artifactIcon.test.ts create mode 100644 src/features/conversation/artifactIcon.ts diff --git a/src/features/conversation/ArtifactCard.tsx b/src/features/conversation/ArtifactCard.tsx index f1b1c445..4c1a8ce7 100644 --- a/src/features/conversation/ArtifactCard.tsx +++ b/src/features/conversation/ArtifactCard.tsx @@ -19,6 +19,7 @@ import { useToolResult } from "../../store/conversationStore"; import { Dot, Ico } from "../../ui/kit"; import { useDisplay } from "../../store/display"; import { artifactHeadline, publishInfo, useArtifacts } from "./artifacts"; +import { artifactFace } from "./artifactIcon"; import { openArtifactView, routeArtifactOpen } from "./artifactOpen"; import { useHostInert } from "./FileMention"; import { basename } from "./toolMeta"; @@ -48,7 +49,9 @@ export function ArtifactCard({ // typed artifact's name (set at creation, while this may be the later data fill) and its type. const artifact = useArtifacts(session).find((a) => (url ? a.url === url : a.versions.some((v) => v.toolUseId === toolUseId))); const typed = info.typed || !!artifact?.typed; - const favicon = field(input, "favicon") ?? artifact?.favicon ?? null; + // `icon` (a word) is the wire today, `favicon` (an emoji) what older transcripts carry — see + // artifactIcon.ts. This publish's own choice wins; the artifact's is the last-known-good. + const favicon = artifactFace(field(input, "icon"), field(input, "favicon")) ?? artifact?.favicon ?? null; const label = field(input, "label")?.trim() || null; const description = field(input, "description")?.trim() || null; const filePath = field(input, "file_path") ?? ""; @@ -106,6 +109,10 @@ export function ArtifactCard({ return (
(
- v{vcount - i} - {v.label ?? "—"} + {/* The number the publish ack named, else the position — see + `artifactVersionFromResult`: an older binary's ack carries none. */} + v{v.version ?? vcount - i} + + {v.isError ? "Publishing failed" : v.label || v.description || "—"} +
))}
diff --git a/src/features/conversation/ConductorConversation.tsx b/src/features/conversation/ConductorConversation.tsx index 020e2d60..3072d5fa 100644 --- a/src/features/conversation/ConductorConversation.tsx +++ b/src/features/conversation/ConductorConversation.tsx @@ -30,7 +30,13 @@ import { ConversationPane } from "./ConversationPane"; import { type ComposerHandle } from "./ConductorComposer"; import { ConductorSidebar } from "./ConductorSidebar"; import { ConversationSidePanel } from "./ConversationSidePanel"; -import { SIDE_PANEL_PX, SIDE_REGION_MIN_PX, sidePanelDocks } from "./sidePanelLayout"; +import { + clampSidePanelWidth, + dockedSidePanelWidth, + floatingSidePanelWidth, + SIDE_REGION_MIN_PX, + sidePanelDocks, +} from "./sidePanelLayout"; import { useDisplay } from "../../store/display"; import { neighborFlex, useFrozenWhile, usePanelSlide } from "../../ui/usePanelSlide"; @@ -147,6 +153,8 @@ function ConversationArea({ const sidePanelPref = useDisplay((s) => s.conversationSidePanel); const panelShown = useConvPanelShown(); const sideRegionOpen = useSideRegionOpen(conv.id); + const convPanelWidth = useEditorStore((s) => s.convPanelWidth); + const setConvPanelWidth = useEditorStore((s) => s.setConvPanelWidth); const areaRef = useRef(null); const areaWidth = useLayoutWidth(areaRef); // Unmeasured (first commit) → assume it docks: the common case, and a wrong guess is @@ -168,13 +176,37 @@ function ConversationArea({ const docksRef = useRef(docks); const docksChanged = docksRef.current !== docks; docksRef.current = docks; + // The width the user dragged the panel to, held inside its own bounds AND inside the room + // actually left beside the conversation — a stored 560 on a narrow window renders at the + // room available, never by pushing the composer under its floor. + const width = + areaWidth === null + ? clampSidePanelWidth(convPanelWidth) + : docks + ? dockedSidePanelWidth(convPanelWidth, areaWidth, sideRegionOpen) + : floatingSidePanelWidth(convPanelWidth, areaWidth); const slide = usePanelSlide({ open: want && docks, axis: "x", enabled: !docksChanged, - restStyle: { flex: `0 0 ${SIDE_PANEL_PX}px`, minWidth: 0, minHeight: 0, display: "flex" }, + restStyle: { flex: `0 0 ${width}px`, minWidth: 0, minHeight: 0, display: "flex" }, }); + // The slot's left edge IS the divider, so the width the user is asking for is simply the + // distance from the pointer to the area's right edge. Zoom needs no correction: the webview + // zooms CSS pixels themselves (see ui/zoom.ts), so client coordinates stay in the same space + // as the widths — unlike a `transform`, which would need visualScale. + const onPanelDrag = (clientX: number) => { + const rect = areaRef.current?.getBoundingClientRect(); + if (rect) setConvPanelWidth(rect.right - clientX); + }; + const panel = ( + <> + + + + ); + return (
{slide.mounted ? ( + // The splitter travels INSIDE the animated slot, as the side region's does, so the + // divider slides in with the panel instead of blinking into place 6px early.
-
- -
+
{panel}
) : null} {want && !docks ? ( @@ -197,13 +229,13 @@ function ConversationArea({ top: 0, right: 0, bottom: 0, - width: SIDE_PANEL_PX, + width, zIndex: 20, display: "flex", boxShadow: "-18px 0 36px -20px #000", }} > - + {panel}
) : null}
diff --git a/src/features/conversation/ConversationSidePanel.module.css b/src/features/conversation/ConversationSidePanel.module.css index 600ccc7e..d0f26417 100644 --- a/src/features/conversation/ConversationSidePanel.module.css +++ b/src/features/conversation/ConversationSidePanel.module.css @@ -9,7 +9,8 @@ display: flex; flex-direction: column; background: var(--wf-bg-2); - border-left: 1px solid var(--wf-line); + /* No left border: the drag splitter now sits there and draws the divider itself (its ::after + is a 1px line in the same token), so keeping one would paint two rules side by side. */ } /* ---- Header: a single tab-like title + close ------------------------------------------- */ @@ -396,25 +397,140 @@ flex-direction: column; gap: 8px; } +/* The card is a CONTAINER (open + version badge + the expanded history), not one button: + nesting a button inside a button is invalid, and the badge must not open the artifact. */ .artCard { display: flex; - align-items: center; - gap: 10px; - width: 100%; + flex-direction: column; min-width: 0; - padding: 7px 8px; border-radius: 8px; background: var(--wf-panel); border: 1px solid var(--wf-line); + transition: border-color 0.12s ease; +} +.artCard:hover, +.artCard[data-open] { + border-color: var(--wf-line-2); +} +.artHead { + display: flex; + align-items: center; + min-width: 0; + padding: 0 6px 0 0; +} +.artOpen { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 7px 4px 7px 8px; + border: 0; + border-radius: 7px; + background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; + transition: background 0.12s ease; } -.artCard:hover { +.artOpen:hover { background: var(--wf-panel-2); - border-color: var(--wf-line-2); +} + +/* « v3 ⌄ » — the version history's disclosure. A single-version artifact keeps the same + badge slot as static text, so the cards line up whether or not they have a history. */ +.artVbtn, +.artV1 { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 2px; + height: 22px; + padding: 0 4px 0 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--wf-tx-lo); + font: inherit; + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.artVbtn { + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.artVbtn:hover { + background: var(--wf-panel-3); + color: var(--wf-tx-hi); +} +.artV1 { + padding-right: 6px; + color: var(--wf-tx-xlo); +} +.artVchev { + width: 11px; + height: 11px; + transform: rotate(-90deg); + transition: transform 0.15s ease; +} +.artVbtn[aria-expanded="true"] .artVchev { + transform: rotate(0deg); +} + +/* The history itself: one row per publish, newest first. Each row GOES TO the message that + published it — the only place that version's contents still exist (see ArtifactVersions). */ +.artVersions { + display: flex; + flex-direction: column; + padding: 2px 4px 4px; + border-top: 1px solid color-mix(in srgb, var(--wf-line) 70%, var(--wf-panel)); +} +.artVrow { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 5px 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.12s ease; +} +.artVrow:hover { + background: var(--wf-panel-2); +} +.artVn { + flex: 0 0 auto; + font-size: 11px; + color: var(--wf-tx-lo); + font-variant-numeric: tabular-nums; +} +.artVrow[data-state="error"] .artVn, +.artVrow[data-state="error"] .artVlabel { + color: var(--wf-err); +} +.artVlabel { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--wf-tx); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.artVgo { + flex: 0 0 auto; + color: var(--wf-tx-lo); + opacity: 0; + transition: opacity 0.12s ease; +} +.artVrow:hover .artVgo { + opacity: 1; } .artTile { flex: 0 0 28px; @@ -453,7 +569,7 @@ opacity: 0; transition: opacity 0.12s ease; } -.artCard:hover .artGo { +.artOpen:hover .artGo { opacity: 1; } diff --git a/src/features/conversation/ConversationSidePanel.tsx b/src/features/conversation/ConversationSidePanel.tsx index 1b9d60c5..277fb732 100644 --- a/src/features/conversation/ConversationSidePanel.tsx +++ b/src/features/conversation/ConversationSidePanel.tsx @@ -37,6 +37,7 @@ import { AssigneePicker } from "../tosse/AssigneePicker"; import { TodoList } from "../todos/TodoList"; import { useArtifacts, type Artifact } from "./artifacts"; import { artifactKind, openArtifactView } from "./artifactOpen"; +import { openConversationAt } from "../../store/threadJump"; import { useClearGoalAction } from "./GoalPopover"; import { useStreamActions } from "./StreamControl"; import s from "./ConversationSidePanel.module.css"; @@ -311,38 +312,114 @@ function ArtifactsSection({ convId, artifacts }: { convId: string; artifacts: Ar ); } -/** One published artifact: favicon tile, title, kind + version count. Opens like every other - * artifact surface (in-app viewer when the local file is still there, else the hosted page). */ +/** + * One published artifact: its face, title, kind and version count. The card opens the artifact + * like every other artifact surface (in-app viewer when the local file is still there, else the + * hosted page); the version badge expands its HISTORY. + * + * ⚠️ The typed/multi-file flags are passed on. Without them a Claude Design canvas would be + * routed to the local preview and render its `canvas.json` as a page — the broken screen + * `routeArtifactOpen` exists to avoid. Every surface must hand it the same facts. + */ function ArtifactCard({ convId, art }: { convId: string; art: Artifact }) { - const kind = artifactKind(art.latestFilePath) === "md" ? "Markdown" : "HTML"; - const versions = art.versions.length; - const open = () => + const [open, setOpen] = useState(false); + const kind = art.typed + ? art.typeName + ? `${art.typeName} artifact` + : "Artifact" + : artifactKind(art.latestFilePath) === "md" + ? "Markdown" + : "HTML"; + const count = art.versions.length; + // The badge shows the ARTIFACT's version number, which is the highest any ack named — not the + // last one, whose publish may have failed (and so carries no number at all). Falls back to the + // count for transcripts written before the CLI put "(Version N)" in the ack. + const latest = + art.versions.reduce((m, v) => (v.version && (m === null || v.version > m) ? v.version : m), null) ?? + count; + const openArtifact = () => openArtifactView({ convId, title: art.title, favicon: art.favicon, url: art.url, filePath: art.latestFilePath, + typed: art.typed, + multiFile: art.multiFile, }); return ( - +
+
+ + {count > 1 ? ( + + ) : ( + v{latest} + )} +
+ {count > 1 && open ? : null} +
+ ); +} + +/** + * The version history of one artifact, newest first — the only way to reach an OLDER version. + * + * ⚠️ There is no per-version link to fabricate: the wire gives ONE canonical URL (claude.ai + * always serves the latest), and each republish overwrites the same local temp file, so an + * earlier version's bytes exist nowhere the app can read. What does survive is the message that + * published it, card and all — so a row scrolls the thread there rather than pretending to open + * something. A version whose publish FAILED says so instead of looking like one you can visit. + */ +function ArtifactVersions({ convId, art }: { convId: string; art: Artifact }) { + return ( +
+ {art.versions + .map((v, i) => ({ v, n: v.version ?? i + 1 })) + .reverse() + .map(({ v, n }) => ( + + ))} +
); } diff --git a/src/features/conversation/artifactIcon.test.ts b/src/features/conversation/artifactIcon.test.ts new file mode 100644 index 00000000..38a626d8 --- /dev/null +++ b/src/features/conversation/artifactIcon.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { artifactFace } from "./artifactIcon"; + +describe("artifactFace", () => { + it("resolves the CURRENT wire: an `icon` WORD, with no favicon at all", () => { + // Verbatim from a 2026-09-20 transcript: `{description, file_path, icon:"sparkle"}`. + expect(artifactFace("sparkle", null)).toBe("✨"); + expect(artifactFace("report", null)).toBe("📑"); + expect(artifactFace("code", null)).toBe("💻"); + expect(artifactFace("chart", null)).toBe("📊"); + expect(artifactFace("calendar", null)).toBe("📅"); + expect(artifactFace("recipe", null)).toBe("🍳"); + expect(artifactFace("map", null)).toBe("🗺️"); + }); + + it("keeps the LEGACY wire working: a `favicon` emoji, with no icon", () => { + // Every transcript written before the change — they are never rewritten. + expect(artifactFace(null, "📖")).toBe("📖"); + expect(artifactFace(null, "🛬")).toBe("🛬"); + expect(artifactFace(null, "✈️")).toBe("✈️"); + }); + + it("prefers the emoji the CLI chose over one we derive, during the overlap", () => { + // Both fields were sent for a couple of days (`icon:"plane"` + `favicon:"✈️"`). + expect(artifactFace("logo", "🛬")).toBe("🛬"); + expect(artifactFace("report", "📑")).toBe("📑"); + }); + + it("takes an emoji arriving on `icon` verbatim rather than looking it up", () => { + expect(artifactFace("🎉", null)).toBe("🎉"); + }); + + it("normalises spelling: case, spaces and separators", () => { + expect(artifactFace("Bar Chart", null)).toBe("📊"); + expect(artifactFace("bar-chart", null)).toBe("📊"); + expect(artifactFace("BARCHART", null)).toBe("📊"); + }); + + it("matches the LONGEST stem, so a short word never eats a specific one", () => { + // "plane" must not be read as "plan". + expect(artifactFace("plane", null)).toBe("✈️"); + expect(artifactFace("plan", null)).toBe("🗺️"); + expect(artifactFace("flightplan", null)).toBe("✈️"); + // "dashboard" is its own entry, not "board". + expect(artifactFace("dashboard", null)).toBe("📊"); + expect(artifactFace("board", null)).toBe("📋"); + }); + + it("says nothing rather than guessing when there is nothing to work with", () => { + expect(artifactFace(null, null)).toBeNull(); + expect(artifactFace("", " ")).toBeNull(); + // An unmapped word: the caller shows its own generic fallback. + expect(artifactFace("zzqqx", null)).toBeNull(); + }); +}); diff --git a/src/features/conversation/artifactIcon.ts b/src/features/conversation/artifactIcon.ts new file mode 100644 index 00000000..1b0441b3 --- /dev/null +++ b/src/features/conversation/artifactIcon.ts @@ -0,0 +1,270 @@ +// The FACE of an artifact — the pictograph shown on its tile, everywhere an artifact is listed +// (inline card, composer popover, side panel, viewer header, prose link card). +// +// ⚠️ THE WIRE CHANGED UNDER US. The `Artifact` tool used to carry `favicon`: an emoji, ready to +// render. It now carries `icon`: "one short generic word for the artifact's browser-tab icon, +// such as chart, calendar, recipe, code or map" — a WORD, not a picture. Checked against the +// transcripts on disk: `favicon` alone up to 2026-09-14, BOTH during a short overlap +// (`icon:"plane"` + `favicon:"✈️"`), and `icon` alone from 2026-09-20 (`icon:"sparkle"`). Reading +// only `favicon` is why every recent artifact showed the empty fallback tile. +// +// So both are read, and a word is turned back into a picture here. Emoji (rather than a glyph +// from the app's own icon set) for two reasons: the vocabulary is OPEN — a 70-glyph UI set has +// nothing for "recipe", "calendar" or "map", so most artifacts would land on the generic +// fallback, which is the very bug being fixed — and a conversation mixing pre- and post-change +// publishes renders as ONE list instead of half emoji, half line art. +// +// Pure, no React: the resolution is a data question, testable on its own. + +/** Emoji for a word, keyed by a normalised stem (letters only, lowercase). + * + * Matched EXACTLY first, then as a substring longest-first — so "barchart" finds `chart`, + * while "plane" keeps its own entry instead of being eaten by `plan`. Ordering the fallback + * scan by stem length is what makes adding a short stem safe. */ +const FACES: Record = { + // Data & reporting + chart: "📊", + graph: "📈", + analytics: "📈", + dashboard: "📊", + metrics: "📈", + stats: "📊", + data: "🗃️", + table: "🗂️", + spreadsheet: "📗", + report: "📑", + summary: "📑", + audit: "🔍", + // Documents & writing + document: "📄", + doc: "📄", + page: "📄", + paper: "📄", + article: "📰", + news: "📰", + note: "📝", + memo: "📝", + letter: "✉️", + mail: "✉️", + form: "📝", + survey: "📝", + quiz: "❓", + spec: "📋", + contract: "📜", + scroll: "📜", + legal: "⚖️", + invoice: "🧾", + receipt: "🧾", + book: "📖", + guide: "📖", + manual: "📖", + glossary: "📖", + library: "📚", + // Planning + checklist: "☑️", + todo: "☑️", + task: "☑️", + list: "📋", + board: "📋", + kanban: "📋", + plan: "🗺️", + roadmap: "🗺️", + map: "🗺️", + timeline: "🕰️", + history: "🕰️", + calendar: "📅", + schedule: "📅", + agenda: "📅", + clock: "⏱️", + timer: "⏱️", + // Engineering + code: "💻", + terminal: "🖥️", + console: "🖥️", + server: "🖥️", + database: "🗄️", + api: "🔌", + plugin: "🔌", + bug: "🐛", + test: "🧪", + experiment: "🧪", + science: "🔬", + lab: "🔬", + build: "🏗️", + deploy: "🚀", + rocket: "🚀", + launch: "🚀", + tool: "🔧", + wrench: "🔧", + gear: "⚙️", + settings: "⚙️", + config: "⚙️", + network: "🕸️", + tree: "🌳", + branch: "🌿", + // Design & media + design: "🎨", + palette: "🎨", + brand: "🎨", + art: "🎨", + paint: "🖌️", + logo: "🏷️", + tag: "🏷️", + label: "🏷️", + image: "🖼️", + photo: "🖼️", + picture: "🖼️", + gallery: "🖼️", + camera: "📸", + screenshot: "📸", + video: "🎬", + film: "🎬", + movie: "🎬", + slide: "📽️", + deck: "📽️", + presentation: "📽️", + music: "🎵", + audio: "🔊", + sound: "🔊", + mic: "🎙️", + podcast: "🎙️", + // Signals + sparkle: "✨", + spark: "✨", + magic: "✨", + star: "⭐", + idea: "💡", + bulb: "💡", + fire: "🔥", + bolt: "⚡", + energy: "⚡", + alert: "⚠️", + warning: "⚠️", + check: "✅", + target: "🎯", + goal: "🎯", + flag: "🚩", + pin: "📌", + bookmark: "🔖", + trophy: "🏆", + award: "🏆", + medal: "🏅", + // People & places + user: "👤", + profile: "👤", + person: "👤", + people: "👥", + team: "👥", + group: "👥", + chat: "💬", + message: "💬", + quote: "💬", + phone: "📱", + mobile: "📱", + globe: "🌍", + world: "🌍", + web: "🌐", + site: "🌐", + link: "🔗", + home: "🏠", + house: "🏠", + building: "🏢", + office: "🏢", + shop: "🛒", + store: "🛒", + cart: "🛒", + ticket: "🎫", + // Money + money: "💰", + budget: "💰", + finance: "💰", + bank: "🏦", + price: "🏷️", + card: "💳", + payment: "💳", + // Security + lock: "🔒", + security: "🔒", + privacy: "🔒", + shield: "🛡️", + key: "🔑", + // Transport & travel + plane: "✈️", + flight: "✈️", + travel: "✈️", + train: "🚆", + car: "🚗", + ship: "🚢", + boat: "⛵", + bike: "🚲", + compass: "🧭", + // Life + recipe: "🍳", + kitchen: "🍳", + food: "🍽️", + restaurant: "🍽️", + coffee: "☕", + health: "🩺", + medical: "🩺", + fitness: "🏋️", + sport: "⚽", + game: "🎮", + heart: "❤️", + leaf: "🌿", + nature: "🌿", + weather: "🌤️", + sun: "☀️", + moon: "🌙", + water: "💧", + // Misc structure + folder: "📁", + file: "📄", + archive: "📦", + package: "📦", + box: "📦", + layers: "🗂️", + grid: "🔲", + search: "🔍", + filter: "🔎", + brain: "🧠", + robot: "🤖", + bot: "🤖", + dna: "🧬", + school: "🎓", + education: "🎓", + vote: "🗳️", + poll: "🗳️", +}; + +/** Stems to scan when the whole word isn't a key, longest first (see {@link FACES}). */ +const STEMS = Object.keys(FACES).sort((a, b) => b.length - a.length); + +/** True when the value is ALREADY a picture (an emoji) rather than a word. Covers the legacy + * `favicon` and any `icon` that happens to arrive as one. */ +function isPictograph(value: string): boolean { + return /\p{Extended_Pictographic}/u.test(value); +} + +/** + * The emoji face for one publish, from its `icon` (word) and/or its `favicon` (legacy emoji), + * or null when neither says anything usable — callers then show their own generic fallback. + * + * An emoji on either field is taken verbatim: a picture the CLI chose beats one we derive. + * A word is normalised to letters (so `bar-chart`, `Bar Chart` and `barchart` agree) and looked + * up whole, then by its longest matching stem. + */ +export function artifactFace( + icon: string | null | undefined, + favicon: string | null | undefined, +): string | null { + for (const raw of [favicon, icon]) { + const v = raw?.trim(); + if (v && isPictograph(v)) return v; + } + const word = (icon ?? favicon ?? "").toLowerCase().replace(/[^a-z]/g, ""); + if (!word) return null; + const exact = FACES[word]; + if (exact) return exact; + for (const stem of STEMS) if (word.includes(stem)) return FACES[stem]; + return null; +} diff --git a/src/features/conversation/artifactOpen.ts b/src/features/conversation/artifactOpen.ts index 91e47ba5..5a9bdb36 100644 --- a/src/features/conversation/artifactOpen.ts +++ b/src/features/conversation/artifactOpen.ts @@ -128,7 +128,9 @@ export function openArtifactView(meta: ArtifactOpenMeta): void { hostedInApp: meta.hostedInApp ?? useDisplay.getState().artifactsInApp, }); if (route.kind === "viewer") { - useEditorStore.getState().openArtifact(route.view); + useEditorStore + .getState() + .openArtifact(route.view, { preemptPanel: useDisplay.getState().artifactHidesSidePanel }); return; } if (route.kind === "browser") { diff --git a/src/features/conversation/artifacts.test.ts b/src/features/conversation/artifacts.test.ts index 3352d950..68b88938 100644 --- a/src/features/conversation/artifacts.test.ts +++ b/src/features/conversation/artifacts.test.ts @@ -616,3 +616,149 @@ describe("canonicalArtifactUrl", () => { expect(canonicalArtifactUrl("")).toBeNull(); }); }); + +describe("the artifact icon wire (favicon → icon)", () => { + it("resolves a publish that only carries the NEW `icon` word", () => { + // Verbatim shape of a 2026-09-20 publish: `icon`, no `favicon` at all. Reading only + // `favicon` is what left every recent artifact on the empty fallback tile. + const e = entryOf( + [{ id: "t1", blocks: [tuse("u1", { file_path: "/tmp/marks.html", icon: "sparkle", description: "Marks" })] }], + { u1: `Published /tmp/marks.html at ${URL_A} (Version 1) Icon: "sparkle".` }, + ); + expect(selectArtifacts(e)[0].favicon).toBe("✨"); + }); + + it("still resolves an OLD publish that only carries `favicon`", () => { + const e = entryOf( + [{ id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html", favicon: "🛬" })] }], + { u1: SHORT("/tmp/x.html", URL_A) }, + ); + expect(selectArtifacts(e)[0].favicon).toBe("🛬"); + }); + + it("keeps last-known-good across a republish that drops the icon", () => { + const e = entryOf( + [ + { id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html", icon: "chart" })] }, + { id: "t2", blocks: [tuse("u2", { file_path: "/tmp/x.html" })] }, + ], + { u1: SHORT("/tmp/x.html", URL_A), u2: SHORT("/tmp/x.html", URL_A) }, + ); + expect(selectArtifacts(e)[0].favicon).toBe("📊"); + }); +}); + +describe("artifact version numbers", () => { + it("reads the number the ack names, per version", () => { + const e = entryOf( + [ + { id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html", label: "first" })] }, + { id: "t2", blocks: [tuse("u2", { file_path: "/tmp/x.html", label: "second" })] }, + { id: "t3", blocks: [tuse("u3", { file_path: "/tmp/x.html", label: "third" })] }, + ], + { + u1: `Published /tmp/x.html at ${URL_A} (Version 1)`, + u2: `Published /tmp/x.html at ${URL_A} (Version 2)`, + u3: `Published /tmp/x.html at ${URL_A} (Version 3)`, + }, + ); + const a = selectArtifacts(e)[0]; + expect(a.versions.map((v) => v.version)).toEqual([1, 2, 3]); + expect(a.versions.map((v) => v.label)).toEqual(["first", "second", "third"]); + }); + + it("is null for an older ack with no suffix, and while a publish is in flight", () => { + const e = entryOf( + [ + { id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html" })] }, + { id: "t2", blocks: [tuse("u2", { file_path: "/tmp/x.html" })] }, + ], + { u1: SHORT("/tmp/x.html", URL_A) }, // u2 has no result yet + ); + expect(selectArtifacts(e)[0].versions.map((v) => v.version)).toEqual([null, null]); + }); + + it("does not renumber when the file was republished after a FAILED attempt", () => { + // A refused publish gets no number: position and version legitimately disagree, so the UI + // must read the ack rather than count rows. + const e = entryOf( + [ + { id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html" })] }, + { id: "t2", blocks: [tuse("u2", { file_path: "/tmp/x.html" })] }, + { id: "t3", blocks: [tuse("u3", { file_path: "/tmp/x.html" })] }, + ], + { + u1: `Published /tmp/x.html at ${URL_A} (Version 1)`, + u2: "Refused: the artifact is locked.", + u3: `Published /tmp/x.html at ${URL_A} (Version 2)`, + }, + ["u2"], + ); + const a = selectArtifacts(e)[0]; + expect(a.versions.map((v) => v.version)).toEqual([1, null, 2]); + expect(a.versions.map((v) => v.isError)).toEqual([false, true, false]); + }); + + it("re-derives when a version's number lands LATE (the memo signature covers versions)", () => { + // A republish's ack only fills in that version's NUMBER — url/title/count are already + // settled — so a signature over the header alone kept the previous array and the number + // never appeared. + clearAllArtifactsCache(); + const turns = [ + { id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html" })] }, + { id: "t2", blocks: [tuse("u2", { file_path: "/tmp/x.html" })] }, + ]; + const pending = entryOf(turns, { u1: `Published /tmp/x.html at ${URL_A} (Version 1)` }); + expect(memoizedArtifacts("s", pending)[0].versions.map((v) => v.version)).toEqual([1, null]); + const landed = entryOf(turns, { + u1: `Published /tmp/x.html at ${URL_A} (Version 1)`, + u2: `Published /tmp/x.html at ${URL_A} (Version 2)`, + }); + expect(memoizedArtifacts("s", landed)[0].versions.map((v) => v.version)).toEqual([1, 2]); + clearArtifactsCache("s"); + }); +}); + +describe("several different artifacts in ONE conversation", () => { + it("keeps them apart, each with its own versions, face and file", () => { + const e = entryOf( + [ + { id: "t1", blocks: [tuse("u1", { file_path: "/tmp/report.html", icon: "report", label: "report" })] }, + { id: "t2", blocks: [tuse("u2", { file_path: "/tmp/deck.html", icon: "plane", label: "deck" })] }, + { id: "t3", blocks: [tuse("u3", { file_path: "/tmp/report.html", label: "report fix" })] }, + ], + { + u1: `Published /tmp/report.html at ${URL_A} (Version 1)`, + u2: `Published /tmp/deck.html at ${URL_B} (Version 1)`, + u3: `Published /tmp/report.html at ${URL_A} (Version 2)`, + }, + ); + const arts = selectArtifacts(e); + expect(arts).toHaveLength(2); + // Order = first appearance, so the list stays stable as republishes land. + expect(arts.map((a) => a.url)).toEqual([URL_A, URL_B]); + expect(arts.map((a) => a.versions.length)).toEqual([2, 1]); + expect(arts.map((a) => a.favicon)).toEqual(["📑", "✈️"]); + expect(arts.map((a) => a.latestFilePath)).toEqual(["/tmp/report.html", "/tmp/deck.html"]); + // The republish belongs to the FIRST artifact, and titles don't leak across. + expect(arts[0].versions.map((v) => v.toolUseId)).toEqual(["u1", "u3"]); + expect(arts[0].title).toBe("report fix"); + expect(arts[1].title).toBe("deck"); + }); + + it("does not merge two artifacts published in the SAME turn under one label", () => { + const e = entryOf( + [ + { + id: "t1", + blocks: [ + tuse("u1", { file_path: "/tmp/a.html", label: "shared-label" }), + tuse("u2", { file_path: "/tmp/b.html", label: "shared-label" }), + ], + }, + ], + { u1: SHORT("/tmp/a.html", URL_A), u2: SHORT("/tmp/b.html", URL_B) }, + ); + expect(selectArtifacts(e).map((a) => a.url)).toEqual([URL_A, URL_B]); + }); +}); diff --git a/src/features/conversation/artifacts.ts b/src/features/conversation/artifacts.ts index 3c520876..a24a85fd 100644 --- a/src/features/conversation/artifacts.ts +++ b/src/features/conversation/artifacts.ts @@ -23,6 +23,7 @@ import type { SessionEntry } from "../../store/types"; import { useConversationStore } from "../../store/conversationStore"; import { field } from "../../agent/ask"; import { resultText } from "../../agent/subagentMeta"; +import { artifactFace } from "./artifactIcon"; import { basename } from "./toolMeta"; /** The canonical hosted-artifact URL shape. The publish tool_result is free text that @@ -149,6 +150,17 @@ export function artifactUrlFromResult(content: JsonValue | undefined): string | return allArtifactUrls(text).find((u) => u !== typeUrl) ?? null; } +/** The version number the publish ack names — `Published at (Version 3)`, a suffix + * the CLI gained in 2.1.270. Null for an older ack that has none, and for a publish still in + * flight. Anchored on the parenthesised form alone, never on the prose around it (which drifts): + * a missing number degrades to counting positions in the list, never to a WRONG number. */ +export function artifactVersionFromResult(content: JsonValue | undefined): number | null { + const m = resultText(content).match(/\(Version (\d+)\)/); + if (!m) return null; + const n = Number(m[1]); + return Number.isSafeInteger(n) && n > 0 ? n : null; +} + /** What one publish tells about the artifact it targets — the per-publish half of the typed * detection, shared by the inline card (one publish) and `selectArtifacts` (all of them). */ export interface PublishInfo { @@ -187,7 +199,13 @@ export interface ArtifactVersion { * type provides the page; the data files come in a later publish). */ filePath: string | null; description: string | null; + /** The emoji face this publish asked for, resolved from its `icon` (a word, the current wire) + * or its `favicon` (an emoji, the legacy one) — see {@link artifactFace}. */ favicon: string | null; + /** The version number the ack named (`(Version 3)`), or null when the ack has none (an older + * binary) or has not landed yet. NOT a position in {@link Artifact.versions}: a publish that + * FAILED gets no number, so the two can legitimately disagree. */ + version: number | null; /** True when THIS publish's tool_result came back `is_error` (a failed/refused publish). * False while still in flight (no result yet) or on success. */ isError: boolean; @@ -204,8 +222,9 @@ export interface Artifact { * publish tool_use and its tool_result landing — and for an artifact whose every publish * FAILED (a failed publish's URL is not an artifact you can open). */ url: string | null; - /** Emoji favicon — of the most recent version that set it (last-known-good, so a republish - * that omits the favicon keeps the prior one), or null. */ + /** Emoji face — of the most recent version that set one (last-known-good, so a republish that + * omits it keeps the prior one), or null. Resolved from the publish's `icon`/`favicon`; see + * {@link artifactFace} for why the wire needs both. */ favicon: string | null; /** Gallery subtitle — of the most recent version that set it (last-known-good), or null. */ description: string | null; @@ -334,7 +353,9 @@ export function selectArtifacts(entry: SessionEntry | undefined): Artifact[] { const label = field(b.input, "label") ?? null; const title = field(b.input, "title")?.trim() || null; const description = field(b.input, "description") ?? null; - const favicon = field(b.input, "favicon") ?? null; + // BOTH icon fields: `icon` (a word) is what the CLI sends now, `favicon` (an emoji) what + // every transcript already on disk carries. Reading only the old one is what emptied the tile. + const favicon = artifactFace(field(b.input, "icon"), field(b.input, "favicon")); const info = publishInfo(b.input, result?.content); const isError = !!result?.isError; // A failed publish's URL is NOT this artifact's identity: the tool refuses, for instance, a @@ -377,7 +398,15 @@ export function selectArtifacts(entry: SessionEntry | undefined): Artifact[] { } if (urlKey) byKey.set(urlKey, art); if (fileKey) byKey.set(fileKey, art); - art.versions.push({ label, toolUseId: b.id, filePath, description, favicon, isError }); + art.versions.push({ + label, + toolUseId: b.id, + filePath, + description, + favicon, + version: artifactVersionFromResult(result?.content), + isError, + }); // Header fields = LAST-KNOWN-GOOD (timeline order = oldest→newest): keep the last non-null we // see, so a republish that omits a field doesn't blank the header. Typed is sticky: one typed // publish makes the whole artifact typed (its page is the type's, whatever a later publish @@ -416,13 +445,21 @@ export function selectArtifacts(entry: SessionEntry | undefined): Artifact[] { /** A cheap content signature — lets {@link memoizedArtifacts} return the SAME array reference * when the derived list is unchanged, so a tool_result for an unrelated tool (frequent) never - * re-renders the chip. */ + * re-renders the chip. + * + * ⚠️ It must cover EVERYTHING a surface reads, the versions included. The version list is on + * screen (side panel, composer popover), and a republish's ack only fills in that version's + * NUMBER — the artifact's own url/title/count are already settled by then. Signing the header + * alone left the previous array in place and the number never appeared. */ function artifactsSig(list: Artifact[]): string { return list .map( (a) => `${a.id}#${a.url ?? ""}#${a.versions.length}#${a.favicon ?? ""}#${a.title}` + - `#${a.latestFilePath ?? ""}#${a.typed ? 1 : 0}#${a.typeName ?? ""}`, + `#${a.latestFilePath ?? ""}#${a.typed ? 1 : 0}#${a.typeName ?? ""}` + + `#${a.versions + .map((v) => `${v.version ?? ""}:${v.isError ? 1 : 0}:${v.label ?? ""}:${v.description ?? ""}`) + .join(",")}`, ) .join("|"); } diff --git a/src/features/conversation/sidePanelLayout.test.ts b/src/features/conversation/sidePanelLayout.test.ts index 7668a224..854ddc21 100644 --- a/src/features/conversation/sidePanelLayout.test.ts +++ b/src/features/conversation/sidePanelLayout.test.ts @@ -1,9 +1,20 @@ import { describe, expect, it } from "vitest"; import { MIN_CONVERSATION_PANE_PX } from "./composerLayout"; -import { SIDE_PANEL_PX, SIDE_REGION_MIN_PX, sidePanelDocks } from "./sidePanelLayout"; +import { + clampSidePanelWidth, + dockedSidePanelWidth, + floatingSidePanelWidth, + SIDE_PANEL_MAX_PX, + SIDE_PANEL_MIN_PX, + SIDE_PANEL_PX, + SIDE_REGION_MIN_PX, + sidePanelDocks, +} from "./sidePanelLayout"; describe("sidePanelDocks", () => { - const alone = MIN_CONVERSATION_PANE_PX + SIDE_PANEL_PX; + // The test is against the panel's FLOOR, not its current width: a window with room for a + // narrow panel docks a narrow panel rather than floating a wide one over the thread. + const alone = MIN_CONVERSATION_PANE_PX + SIDE_PANEL_MIN_PX; const withRegion = alone + SIDE_REGION_MIN_PX; it("docks when the conversation keeps its floor beside the panel", () => { @@ -22,3 +33,53 @@ describe("sidePanelDocks", () => { expect(sidePanelDocks(withRegion - 1, true)).toBe(false); }); }); + +describe("clampSidePanelWidth", () => { + it("holds a dragged width inside the panel's own bounds", () => { + expect(clampSidePanelWidth(420)).toBe(420); + expect(clampSidePanelWidth(10)).toBe(SIDE_PANEL_MIN_PX); + expect(clampSidePanelWidth(5000)).toBe(SIDE_PANEL_MAX_PX); + }); + + it("rounds, and falls back to the default for a value that is not a number", () => { + expect(clampSidePanelWidth(420.6)).toBe(421); + expect(clampSidePanelWidth(Number.NaN)).toBe(SIDE_PANEL_PX); + expect(clampSidePanelWidth(Number.POSITIVE_INFINITY)).toBe(SIDE_PANEL_PX); + }); +}); + +describe("dockedSidePanelWidth", () => { + const roomy = MIN_CONVERSATION_PANE_PX + SIDE_PANEL_MAX_PX + 200; + + it("gives the width asked for when there is room for it", () => { + expect(dockedSidePanelWidth(480, roomy, false)).toBe(480); + }); + + it("caps at the room left beside the conversation rather than crushing it", () => { + // Exactly 400px past the conversation's floor: a 520 request renders at 400. + const area = MIN_CONVERSATION_PANE_PX + 400; + expect(dockedSidePanelWidth(520, area, false)).toBe(400); + }); + + it("counts the side region's floor when it is open", () => { + const area = MIN_CONVERSATION_PANE_PX + SIDE_REGION_MIN_PX + 400; + expect(dockedSidePanelWidth(520, area, true)).toBe(400); + expect(dockedSidePanelWidth(520, area, false)).toBe(520); + }); + + it("never renders below the floor — below it the panel floats instead of docking", () => { + const tooTight = MIN_CONVERSATION_PANE_PX + 40; + expect(sidePanelDocks(tooTight, false)).toBe(false); + expect(dockedSidePanelWidth(SIDE_PANEL_PX, tooTight, false)).toBe(SIDE_PANEL_MIN_PX); + }); +}); + +describe("floatingSidePanelWidth", () => { + it("ignores the conversation's floor (it overlays rather than sharing the row)", () => { + expect(floatingSidePanelWidth(480, MIN_CONVERSATION_PANE_PX + 40)).toBe(480); + }); + + it("still never hangs off the window", () => { + expect(floatingSidePanelWidth(480, 300)).toBe(300); + }); +}); diff --git a/src/features/conversation/sidePanelLayout.ts b/src/features/conversation/sidePanelLayout.ts index c7a7c1f8..44cb1de3 100644 --- a/src/features/conversation/sidePanelLayout.ts +++ b/src/features/conversation/sidePanelLayout.ts @@ -1,6 +1,6 @@ // Geometry of the conversation side panel — pure, so the "does it fit?" rule is testable. // -// The panel is a fixed-width column at the far right of the conversation view. It must never +// The panel is a resizable column at the far right of the conversation view. It must never // crush the conversation below the width its composer needs, nor the editor/terminal region // below its own floor. When the three cannot sit side by side, the panel stops PUSHING the // layout and floats OVER the right edge instead (its open/closed state is untouched: an open @@ -8,23 +8,80 @@ import { MIN_CONVERSATION_PANE_PX } from "./composerLayout"; -/** Width of the conversation side panel, px. */ +/** + * Every width here is the width of the panel's SLOT — the 6px splitter that drags it included, + * since the splitter lives inside the animated slot (so the divider slides in with the panel) + * and therefore counts against the same budget. Mirrors {@link SIDE_REGION_MIN_PX}. + */ + +/** Default width of the conversation side panel, px — what it opens at before anyone drags it. */ export const SIDE_PANEL_PX = 340; +/** + * Narrowest the panel may be dragged to. + * + * 280px of content + the 6px splitter. The floor is set by the panel's own rows, not by taste: + * at 280 the header still holds its title, its chord badge and the close button; the session + * footer's rows still fit an icon, a two-line label and their two 26px buttons; and the TOSSE + * card's status chip and assignee picker still share one line before wrapping. + */ +export const SIDE_PANEL_MIN_PX = 286; + +/** Widest the panel may be dragged to. Past this it stops being a column of status and starts + * eating the thread, which is the surface the window is for. */ +export const SIDE_PANEL_MAX_PX = 560; + /** Narrowest the editor/terminal side region may be dragged to: the 280px the panel itself * needs, plus the 6px splitter — which lives INSIDE the animated slot (so the divider slides * in with the panel), and therefore counts against the same minimum. */ export const SIDE_REGION_MIN_PX = 286; +/** How much of `areaPx` is left for the panel once the conversation — and the editor/terminal/ + * Git region when it is open — keep their floors. May be negative. */ +export function sidePanelRoom(areaPx: number, sideRegionOpen: boolean): number { + return areaPx - MIN_CONVERSATION_PANE_PX - (sideRegionOpen ? SIDE_REGION_MIN_PX : 0); +} + /** * Whether the side panel can DOCK (take its own column) in an area `areaPx` wide — the width * shared by the conversation, the editor/terminal/Git region when it is open, and the panel. * + * The test is against the panel's MINIMUM, not its current width: a window with room for a + * narrow panel docks a narrow panel rather than floating a wide one over the thread. What it + * then actually gets is {@link dockedSidePanelWidth}. + * * `sideRegionOpen` must be true whenever that region shows ANYTHING (editor, terminal, Git, * an artifact or a TOSSE task): each of them claims its floor next to the conversation. */ export function sidePanelDocks(areaPx: number, sideRegionOpen: boolean): boolean { - const needed = - MIN_CONVERSATION_PANE_PX + (sideRegionOpen ? SIDE_REGION_MIN_PX : 0) + SIDE_PANEL_PX; - return areaPx >= needed; + return sidePanelRoom(areaPx, sideRegionOpen) >= SIDE_PANEL_MIN_PX; +} + +/** A width the user asked for, held inside the panel's own bounds. What gets PERSISTED — it + * must not depend on the window that happened to be open when the drag ended. */ +export function clampSidePanelWidth(desiredPx: number): number { + if (!Number.isFinite(desiredPx)) return SIDE_PANEL_PX; + return Math.min(SIDE_PANEL_MAX_PX, Math.max(SIDE_PANEL_MIN_PX, Math.round(desiredPx))); +} + +/** + * The width a DOCKED panel actually gets: what the user asked for, capped by the room left + * beside the conversation and the side region. Never below the panel's floor — below it the + * panel doesn't dock at all ({@link sidePanelDocks}), so clamping up here is what keeps a + * shrinking window from rendering a squashed column for the frame before it floats. + */ +export function dockedSidePanelWidth( + desiredPx: number, + areaPx: number, + sideRegionOpen: boolean, +): number { + const want = clampSidePanelWidth(desiredPx); + return Math.max(SIDE_PANEL_MIN_PX, Math.min(want, sidePanelRoom(areaPx, sideRegionOpen))); +} + +/** The width a FLOATING panel gets: the same request, capped only by the window itself (it + * overlays the thread rather than sharing the row, so the conversation's floor doesn't apply + * — but a panel wider than the area would hang off the edge). */ +export function floatingSidePanelWidth(desiredPx: number, areaPx: number): number { + return Math.min(clampSidePanelWidth(desiredPx), Math.max(0, areaPx)); } diff --git a/src/features/conversation/useThreadJumpTarget.ts b/src/features/conversation/useThreadJumpTarget.ts index baa412e3..caa8fcb5 100644 --- a/src/features/conversation/useThreadJumpTarget.ts +++ b/src/features/conversation/useThreadJumpTarget.ts @@ -47,6 +47,11 @@ function locate( sent: { toolUseId: string | null }, ): HTMLElement | null { if (anchor.kind === "received") return byData(root, "data-agent-msg", "agentMsg", anchor.messageId); + // An artifact publish is its own segment — never folded by clean output — so the card is + // already mounted whenever its turn is; no fold to open, unlike a `send_message` card. + if (anchor.kind === "artifact") { + return byData(root, "data-artifact-publish", "artifactPublish", anchor.toolUseId); + } sent.toolUseId ??= findSentMessageToolUse( useConversationStore.getState().sessions[session], anchor.messageId, @@ -135,7 +140,9 @@ export function useThreadJumpTarget( pushInfoToast( anchor.kind === "sent" ? "Couldn't find where that message was sent in this conversation." - : "Couldn't find that message in this conversation.", + : anchor.kind === "artifact" + ? "Couldn't find where that version was published in this conversation." + : "Couldn't find that message in this conversation.", ); return; } diff --git a/src/features/editor/editorStore.test.ts b/src/features/editor/editorStore.test.ts index d0be97dc..23547ff8 100644 --- a/src/features/editor/editorStore.test.ts +++ b/src/features/editor/editorStore.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { useAppErrors } from "../../store/appErrors"; import { resetMockDisk, touchMockFile } from "../../ipc/mock/mockBindings"; import { ancestorDirs, useEditorStore, type FileBuffer } from "./editorStore"; +import { SIDE_PANEL_MAX_PX, SIDE_PANEL_MIN_PX } from "../conversation/sidePanelLayout"; const CONV = "conv-1"; const ROOT = "/repo"; @@ -842,11 +843,18 @@ describe("explorer mutations — folder rename rebases open buffers", () => { describe("conversation side panel toggle", () => { const shown = () => { const s = useEditorStore.getState(); - return s.convPanelOpen && !s.convPanelYielded; + return s.convPanelOpen && !s.convPanelYielded && !s.convPanelPreempted; }; beforeEach(() => { - useEditorStore.setState({ convPanelOpen: true, convPanelYielded: false }); + useEditorStore.setState({ + convPanelOpen: true, + convPanelYielded: false, + convPanelPreempted: false, + artifactView: null, + tosseTaskView: null, + gitOpen: false, + }); }); it("closes a visible panel and reopens a closed one", () => { @@ -881,3 +889,93 @@ describe("conversation side panel toggle", () => { expect(s.terminalOpen).toBe(true); }); }); + +describe("the panel steps aside for an artifact preview", () => { + const VIEW = { + convId: "c1", + title: "Demo", + favicon: "✨", + url: null, + filePath: "/tmp/x.html", + kind: "html" as const, + }; + const shown = () => { + const s = useEditorStore.getState(); + return s.convPanelOpen && !s.convPanelYielded && !s.convPanelPreempted; + }; + + beforeEach(() => { + useEditorStore.setState({ + convPanelOpen: true, + convPanelYielded: false, + convPanelPreempted: false, + artifactView: null, + tosseTaskView: null, + gitOpen: false, + open: false, + terminalOpen: false, + }); + }); + + it("hides a visible panel while the preview is up, and brings it back on close", () => { + useEditorStore.getState().openArtifact(VIEW, { preemptPanel: true }); + expect(shown()).toBe(false); + // ⚠️ The user's own answer is untouched: only the transient flag moved. + expect(useEditorStore.getState().convPanelOpen).toBe(true); + useEditorStore.getState().closeArtifact(); + expect(shown()).toBe(true); + }); + + it("never OPENS a panel the user had closed", () => { + useEditorStore.setState({ convPanelOpen: false }); + useEditorStore.getState().openArtifact(VIEW, { preemptPanel: true }); + expect(useEditorStore.getState().convPanelPreempted).toBe(false); + useEditorStore.getState().closeArtifact(); + expect(shown()).toBe(false); + expect(useEditorStore.getState().convPanelOpen).toBe(false); + }); + + it("never claims a panel that had already stepped aside for lack of room", () => { + useEditorStore.setState({ convPanelYielded: true }); + useEditorStore.getState().openArtifact(VIEW, { preemptPanel: true }); + expect(useEditorStore.getState().convPanelPreempted).toBe(false); + }); + + it("does nothing when the preference is off", () => { + useEditorStore.getState().openArtifact(VIEW); + expect(shown()).toBe(true); + }); + + it("gives the panel back when the side region is taken over instead of closed", () => { + // ⌘B while an artifact is up: the editor clears the viewer, so the reason the panel was + // away is gone too — it must not stay hidden with nothing previewing. + useEditorStore.getState().openArtifact(VIEW, { preemptPanel: true }); + useEditorStore.getState().setOpen(true); + expect(useEditorStore.getState().artifactView).toBeNull(); + expect(shown()).toBe(true); + }); + + it("gives the panel back when a TOSSE task takes the region over", () => { + useEditorStore.getState().openArtifact(VIEW, { preemptPanel: true }); + useEditorStore.getState().openTosseTask({ convId: "c1", taskId: "t1" }); + expect(shown()).toBe(true); + }); + + it("asking for the panel outranks the preview — and the preview stays up", () => { + useEditorStore.getState().openArtifact(VIEW, { preemptPanel: true }); + useEditorStore.getState().toggleConvPanel(); + expect(shown()).toBe(true); + expect(useEditorStore.getState().artifactView).not.toBeNull(); + }); +}); + +describe("conversation side panel width", () => { + it("persists a dragged width, held inside the panel's bounds", () => { + useEditorStore.getState().setConvPanelWidth(420); + expect(useEditorStore.getState().convPanelWidth).toBe(420); + useEditorStore.getState().setConvPanelWidth(10); + expect(useEditorStore.getState().convPanelWidth).toBe(SIDE_PANEL_MIN_PX); + useEditorStore.getState().setConvPanelWidth(9999); + expect(useEditorStore.getState().convPanelWidth).toBe(SIDE_PANEL_MAX_PX); + }); +}); diff --git a/src/features/editor/editorStore.ts b/src/features/editor/editorStore.ts index c6f2a3b6..67ace260 100644 --- a/src/features/editor/editorStore.ts +++ b/src/features/editor/editorStore.ts @@ -20,6 +20,7 @@ import type { FileStat, FsEntry } from "../../ipc/client"; import { useAppErrors } from "../../store/appErrors"; import { baseName, dirName, imageMimeForPath, isImagePath, isPdfPath, languageForPath } from "./language"; import { isWithin, joinPath, uniqueDest, validateName } from "./fileOps"; +import { clampSidePanelWidth, SIDE_PANEL_PX } from "../conversation/sidePanelLayout"; /** Debounce before an edited buffer is autosaved to disk (ms). */ const AUTOSAVE_MS = 1000; @@ -253,12 +254,24 @@ interface EditorState { * never competes with the editor/terminal/Git for the same space. Only read while the * `conversationSidePanel` display pref is on. */ convPanelOpen: boolean; + /** Width of the conversation side panel's slot (splitter included), px — dragged by its + * divider, persisted like `treeWidth`. Held inside the panel's own bounds here; the room + * actually available is applied at render (see sidePanelLayout). */ + convPanelWidth: number; + setConvPanelWidth: (w: number) => void; /** The open panel has STEPPED ASIDE because it cannot dock (not enough width beside the * conversation and the side region). Transient, never persisted: set and cleared by the * layout as the room comes and goes; an explicit open (toggle, ⌘I, summary line) clears * it so the panel floats over the edge instead. Read through {@link useConvPanelShown}. */ convPanelYielded: boolean; setConvPanelYielded: (yielded: boolean) => void; + /** The open panel has been PUT AWAY for the length of an artifact preview: an artifact and + * the panel are both "what this conversation produced", and showing them at once leaves the + * thread a sliver. Transient, never persisted — `convPanelOpen` keeps the user's own answer, + * so closing the artifact brings the panel back exactly as it was, and a panel that was + * already closed is never opened by an artifact. Cleared by any explicit toggle (the user + * asking for the panel outranks this) and whenever the artifact goes away. */ + convPanelPreempted: boolean; // ---- Artifact viewer (in-memory, transient) ---- /** The artifact open in the side-region viewer, or null. Cleared by every side-region toggle @@ -266,8 +279,11 @@ interface EditorState { * whichever way they flip. */ artifactView: ArtifactView | null; /** Open an artifact in the side-region viewer (closes Git FIRST — see the ordering trap in the - * implementation — then takes over the side region). */ - openArtifact: (view: ArtifactView) => void; + * implementation — then takes over the side region). `preemptPanel` asks for the conversation + * side panel to be put away for the length of the preview (see {@link convPanelPreempted}); + * the caller owns that decision because it is a display PREFERENCE, which this store doesn't + * read. */ + openArtifact: (view: ArtifactView, opts?: { preemptPanel?: boolean }) => void; /** Close the artifact viewer (the side region falls back to editor/terminal if open). */ closeArtifact: () => void; @@ -424,6 +440,8 @@ interface LayoutPrefs { treeCollapsed: boolean; /** The conversation side panel is shown. */ convPanelOpen: boolean; + /** Width of its slot, px (the splitter included). */ + convPanelWidth: number; } const DEFAULT_LAYOUT: LayoutPrefs = { @@ -443,6 +461,7 @@ const DEFAULT_LAYOUT: LayoutPrefs = { // Open by default: it is where the conversation's state now lives (the header only // carries actions), so a first launch must show it rather than hide it behind a toggle. convPanelOpen: true, + convPanelWidth: SIDE_PANEL_PX, }; function loadLayout(): LayoutPrefs { @@ -474,6 +493,8 @@ function loadLayout(): LayoutPrefs { treeWidth: typeof p.treeWidth === "number" ? clamp(p.treeWidth, 120, 600) : DEFAULT_LAYOUT.treeWidth, treeCollapsed: typeof p.treeCollapsed === "boolean" ? p.treeCollapsed : DEFAULT_LAYOUT.treeCollapsed, convPanelOpen: typeof p.convPanelOpen === "boolean" ? p.convPanelOpen : DEFAULT_LAYOUT.convPanelOpen, + convPanelWidth: + typeof p.convPanelWidth === "number" ? clampSidePanelWidth(p.convPanelWidth) : DEFAULT_LAYOUT.convPanelWidth, }; } catch { return DEFAULT_LAYOUT; @@ -497,6 +518,7 @@ function saveLayout(s: EditorState): void { treeWidth: s.treeWidth, treeCollapsed: s.treeCollapsed, convPanelOpen: s.convPanelOpen, + convPanelWidth: s.convPanelWidth, }; try { localStorage.setItem(LS_KEY, JSON.stringify(prefs)); @@ -845,6 +867,9 @@ export const useEditorStore = create()((set, get) => { const clearArtifact = () => { if (get().artifactView) set({ artifactView: null }); if (get().tosseTaskView) set({ tosseTaskView: null }); + // The panel was only put away FOR the preview: whichever way the preview ends — the close + // button, or a side-region toggle taking the region over — it comes straight back. + if (get().convPanelPreempted) set({ convPanelPreempted: false }); }; return { @@ -854,22 +879,29 @@ export const useEditorStore = create()((set, get) => { artifactView: null, tosseTaskView: null, - openArtifact: (view) => { + openArtifact: (view, opts) => { // ⚠️ ORDER MATTERS — close Git BEFORE setting the view, never after. `setGitOpen` clears the // artifact view UNCONDITIONALLY (see clearArtifact), so the old "set, then close Git" // ordering would immediately wipe the artifact we were just asked to open. Do not swap - // these two lines back. + // these two lines back. Same reason the preemption is decided AFTER: setGitOpen → + // clearArtifact would reset it. if (get().gitOpen) get().setGitOpen(false); // viewer and Git are mutually exclusive - set({ artifactView: view }); + // Put the side panel away only if it is actually ON SCREEN: an artifact must never OPEN a + // panel the user had closed (or one that had already stepped aside), because closing the + // artifact would then reveal a panel nobody asked for. + const preempt = + !!opts?.preemptPanel && get().convPanelOpen && !get().convPanelYielded; + set({ artifactView: view, convPanelPreempted: preempt }); }, - closeArtifact: () => set({ artifactView: null }), + closeArtifact: () => set({ artifactView: null, convPanelPreempted: false }), openTosseTask: (view) => { // Same ordering trap as `openArtifact`: `setGitOpen` clears the transient side-region // views unconditionally, so Git must be closed BEFORE the view is set, never after. if (get().gitOpen) get().setGitOpen(false); - // One transient view at a time — the side region cannot show two things. - set({ artifactView: null, tosseTaskView: view }); + // One transient view at a time — the side region cannot show two things. The artifact + // leaving takes its panel preemption with it (only artifacts put the panel away). + set({ artifactView: null, tosseTaskView: view, convPanelPreempted: false }); }, closeTosseTask: () => set({ tosseTaskView: null }), @@ -922,22 +954,26 @@ export const useEditorStore = create()((set, get) => { // as closed, so pressing the toggle then brings it back (floating) rather than persisting // "closed" for a panel that was already invisible — one press, one visible effect. toggleConvPanel: () => { - const { convPanelOpen, convPanelYielded } = get(); - if (convPanelOpen && !convPanelYielded) { + const { convPanelOpen, convPanelYielded, convPanelPreempted } = get(); + if (convPanelOpen && !convPanelYielded && !convPanelPreempted) { withLayout({ convPanelOpen: false }); return; } - set({ convPanelYielded: false }); + // Asking for the panel outranks both reasons it was taken away: no room (→ float) and an + // artifact preview (→ share the row with it). + set({ convPanelYielded: false, convPanelPreempted: false }); withLayout({ convPanelOpen: true }); }, setConvPanelOpen: (convPanelOpen) => { - if (convPanelOpen) set({ convPanelYielded: false }); + if (convPanelOpen) set({ convPanelYielded: false, convPanelPreempted: false }); withLayout({ convPanelOpen }); }, + setConvPanelWidth: (w) => withLayout({ convPanelWidth: clampSidePanelWidth(w) }), convPanelYielded: false, setConvPanelYielded: (convPanelYielded) => { if (get().convPanelYielded !== convPanelYielded) set({ convPanelYielded }); }, + convPanelPreempted: false, ensureConv: (convId, root) => { const cur = get().byConv[convId]; @@ -1521,11 +1557,12 @@ export const useSideRegionOpen = (convId: string | null) => (convId !== null && (s.artifactView?.convId === convId || s.tosseTaskView?.convId === convId)), ); -/** Whether the conversation side panel is ON SCREEN (docked or floating): open, and not - * stepped aside for lack of room. What every surface keyed on "is the panel visible" reads — - * the header toggle's lit state, the summary line that stands in for a hidden panel. */ +/** Whether the conversation side panel is ON SCREEN (docked or floating): open, not stepped + * aside for lack of room, and not put away for an artifact preview. What every surface keyed + * on "is the panel visible" reads — the header toggle's lit state, the summary line that + * stands in for a hidden panel. */ export const useConvPanelShown = () => - useEditorStore((s) => s.convPanelOpen && !s.convPanelYielded); + useEditorStore((s) => s.convPanelOpen && !s.convPanelYielded && !s.convPanelPreempted); export const useEditorLayout = () => useEditorStore( useShallow((s) => ({ diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index f605bc56..3514fd00 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -779,6 +779,7 @@ function ThreadPrefs() { const messageControls = useDisplay((s) => s.messageControls); const clickableFileMentions = useDisplay((s) => s.clickableFileMentions); const artifactsInApp = useDisplay((s) => s.artifactsInApp); + const artifactHidesSidePanel = useDisplay((s) => s.artifactHidesSidePanel); const set = useDisplay((s) => s.set); return ( <> @@ -918,6 +919,20 @@ function ThreadPrefs() { onChange={(v) => set({ artifactsInApp: v })} label="Show claude.ai-hosted artifacts in the side panel" /> + + Puts the conversation panel away while an artifact preview is open, + and brings it back when you close the preview — side by side they leave the thread a + sliver. On by default. It only ever hides a panel that was already + on screen, so an artifact never opens one you had closed. Off → both stay up. + + } + checked={artifactHidesSidePanel} + onChange={(v) => set({ artifactHidesSidePanel: v })} + label="Hide the conversation panel while an artifact is open" + /> diff --git a/src/features/settings/settingsSearch.ts b/src/features/settings/settingsSearch.ts index 2d9b36ed..589a2fcc 100644 --- a/src/features/settings/settingsSearch.ts +++ b/src/features/settings/settingsSearch.ts @@ -58,6 +58,7 @@ export const SETTINGS_INDEX: readonly SettingEntry[] = [ { title: "Message controls", section: "display", sub: "thread", group: "Thread", keywords: "rewind fork hover controls rembobiner display affichage" }, { title: "Clickable filename on Read/Write rows", section: "display", sub: "thread", group: "Thread", keywords: "file mention path link chemin cliquable display affichage" }, { title: "Show hosted artifacts in Flight Deck", section: "display", sub: "thread", group: "Thread", keywords: "artifact artefact claude design canvas claude.ai webview browser navigateur panel panneau" }, + { title: "Hide the conversation panel while previewing an artifact", section: "display", sub: "thread", group: "Thread", keywords: "artifact artefact preview aperçu conversation panel panneau side sidebar masquer cacher hide replier" }, { title: "Time on sidebar rows", section: "display", sub: "timing", group: "Durations & timing", keywords: "sidebar row timer counter chrono temps ligne conversation barre laterale" }, { title: "Turn duration", section: "display", sub: "timing", group: "Durations & timing", keywords: "time elapsed seconds duree tour" }, { title: "Model time", section: "display", sub: "timing", group: "Durations & timing", keywords: "api duration breakdown" }, diff --git a/src/ipc/mock/mockBindings.ts b/src/ipc/mock/mockBindings.ts index 3067c7ee..203d66fd 100644 --- a/src/ipc/mock/mockBindings.ts +++ b/src/ipc/mock/mockBindings.ts @@ -1647,6 +1647,7 @@ export const mockCommands = { else if (demo === "remotelink") driver.startRemoteLink(); else if (demo === "remotelinkblocked") driver.startRemoteLinkBlocked("ssh_key_refused"); else if (demo === "remotelinkblockedhost") driver.startRemoteLinkBlocked("ssh_host_key_changed"); + else if (demo === "artifacts") driver.startPageArtifacts(); else driver.start(); // A stable-ish wire uuid so the demo exercises the same "this bubble is addressable" // path as production (the demo has no queue, so cancelling it always reports false). diff --git a/src/ipc/mock/scenario.ts b/src/ipc/mock/scenario.ts index eda514a4..3cb50325 100644 --- a/src/ipc/mock/scenario.ts +++ b/src/ipc/mock/scenario.ts @@ -1228,6 +1228,97 @@ export class ScenarioDriver { this.step(40, () => this.emit.state(idleState())); } + /** + * Page-artifact demo (`?demo=artifacts`): TWO different artifacts in one conversation, one of + * them republished twice — the case the side panel's list and its version history exist for. + * + * Acks are the CURRENT wire, verbatim: the 2.1.272+ URL shape, the `(Version N)` suffix, the + * `Icon: "…"` line — and inputs carrying `icon` (a WORD) with NO `favicon`, which is what the + * binary sends since 2026-09-15. The last publish still uses the legacy `favicon` emoji, so + * one run shows both halves of the wire resolving to a face side by side. + */ + startPageArtifacts() { + this.reset(); + this.emit.state({ ...this.busyState }); + const dir = "/private/tmp/claude-501/demo/scratchpad"; + const reportUrl = "https://claude.ai/artifact/8cvJy8RumYUbh22HJSWarK"; + const deckUrl = "https://claude.ai/artifact/VscjxCuqtLtw9eKDgNwrPu"; + const publish = (id: string, path: string, input: Record) => + ({ type: "tool_use", id, name: "Artifact", input: { file_path: `${dir}/${path}`, ...input } }) as const; + const ack = (id: string, path: string, url: string, n: number, icon?: string) => + this.emit.item({ + kind: "tool_result", + tool_use_id: id, + content: [ + { + type: "text", + text: + `Published ${dir}/${path} at ${url} (Version ${n})` + + (icon ? ` Icon: "${icon}".` : "") + + "\n\nLive subscription: none — this is a print (-p), cloud, subagent, or teammate session.", + }, + ], + is_error: false, + parent_tool_use_id: null, + }); + + this.step(200, () => + this.emit.item({ kind: "message_started", id: "m1", role: "assistant", parent_tool_use_id: null }), + ); + const t1 = "I've published the audit report and the launch deck.\n\n"; + this.streamText("m1", t1); + this.step(150, () => + this.emit.item({ + kind: "assistant_message", + id: "m1", + parent_tool_use_id: null, + blocks: [ + { type: "text", text: t1 }, + publish("toolu_r1", "audit-report.html", { icon: "report", description: "Q3 accessibility audit" }), + publish("toolu_d1", "launch-deck.html", { icon: "plane", description: "Launch readiness" }), + ], + }), + ); + this.step(320, () => ack("toolu_r1", "audit-report.html", reportUrl, 1, "report")); + this.step(260, () => ack("toolu_d1", "launch-deck.html", deckUrl, 1, "plane")); + + this.step(300, () => + this.emit.item({ kind: "message_started", id: "m2", role: "assistant", parent_tool_use_id: null }), + ); + this.step(150, () => + this.emit.item({ + kind: "assistant_message", + id: "m2", + parent_tool_use_id: null, + blocks: [publish("toolu_r2", "audit-report.html", { icon: "report", label: "contrast pass" })], + }), + ); + this.step(320, () => ack("toolu_r2", "audit-report.html", reportUrl, 2)); + + this.step(300, () => + this.emit.item({ kind: "message_started", id: "m3", role: "assistant", parent_tool_use_id: null }), + ); + const t3 = "Third pass on the report — the tables now read at 320px."; + this.streamText("m3", t3, 3, 18); + this.step(150, () => + this.emit.item({ + kind: "assistant_message", + id: "m3", + parent_tool_use_id: null, + blocks: [ + { type: "text", text: t3 }, + // The LEGACY field, on purpose: a conversation can hold both wires at once. + publish("toolu_r3", "audit-report.html", { favicon: "📑", label: "responsive tables" }), + ], + }), + ); + this.step(320, () => ack("toolu_r3", "audit-report.html", reportUrl, 3)); + this.step(200, () => + this.emit.item({ kind: "turn_result", subtype: "success", is_error: false, result: null, api_error_status: null, total_cost_usd: 0.006, num_turns: 3, duration_ms: 4200, duration_api_ms: 3300, ttft_ms: 460 }), + ); + this.step(40, () => this.emit.state(idleState())); + } + /** * Background-monitor demo (`?demo=monitor`): the agent launches the `Monitor` tool — * a live watch whose every stdout line is an event (read from disk, NOT the wire). One diff --git a/src/store/display.ts b/src/store/display.ts index f1e95a75..49f8f3b1 100644 --- a/src/store/display.ts +++ b/src/store/display.ts @@ -191,6 +191,14 @@ export interface DisplayPrefs { * {@link openArtifactView} and the {@link ArtifactViewer}'s missing-file fallback. */ artifactsInApp: boolean; + /** Put the conversation side panel away while an artifact preview is open, and bring it back + * when the preview closes. ON by default: the panel and the preview are both "what this + * conversation produced", and side by side they leave the thread a sliver. It only ever + * HIDES a panel that was on screen — an artifact never opens one that was closed, so closing + * the preview can't reveal something nobody asked for. Off → both stay up, the panel floating + * over the right edge when the row runs out of room. Read by {@link openArtifactView}. */ + artifactHidesSidePanel: boolean; + /** Show the TOSSE mark on a repository's sidebar header — solid when the folder is * associated with a CRM repository, hollow-on-hover when it is not (an invitation to * associate it by hand). Clicking it opens that repository's TOSSE card. ON by default. @@ -362,6 +370,7 @@ const DEFAULTS: DisplayPrefs = { clickableFileMentions: true, tosseToolCards: true, artifactsInApp: true, + artifactHidesSidePanel: true, tosseRepoBadge: true, tosseTasksView: true, ideView: true, @@ -442,6 +451,7 @@ export const useDisplay = create((set) => ({ clickableFileMentions: patch.clickableFileMentions ?? s.clickableFileMentions, tosseToolCards: patch.tosseToolCards ?? s.tosseToolCards, artifactsInApp: patch.artifactsInApp ?? s.artifactsInApp, + artifactHidesSidePanel: patch.artifactHidesSidePanel ?? s.artifactHidesSidePanel, tosseRepoBadge: patch.tosseRepoBadge ?? s.tosseRepoBadge, tosseTasksView: patch.tosseTasksView ?? s.tosseTasksView, ideView: patch.ideView ?? s.ideView, diff --git a/src/store/threadJump.ts b/src/store/threadJump.ts index d9f4fc77..11bae6bc 100644 --- a/src/store/threadJump.ts +++ b/src/store/threadJump.ts @@ -17,7 +17,12 @@ export type JumpAnchor = /** The recipient's card for a message (`data-agent-msg`). */ | { kind: "received"; messageId: string } /** The sender's `send_message` card, found through the message id its result echoes. */ - | { kind: "sent"; messageId: string }; + | { kind: "sent"; messageId: string } + /** One publish of an artifact — its inline card (`data-artifact-publish`). How an older + * VERSION is reached: claude.ai only ever serves the latest, and the local temp file is + * overwritten on every republish, so the card where that version was published is the one + * place its contents are still on show. */ + | { kind: "artifact"; toolUseId: string }; export interface JumpRequest { convId: string; From 1613ef0c810bb878a6b1639644894812d0858858 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Sun, 20 Sep 2026 20:16:22 +0200 Subject: [PATCH 04/24] =?UTF-8?q?fix(artifacts):=20a=20TYPED=20artifact=20?= =?UTF-8?q?names=20no=20icon=20=E2=80=94=20fall=20back=20to=20its=20type,?= =?UTF-8?q?=20one=20generic=20mark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon-wire fix covered page artifacts, but a Claude Design canvas still showed the blank tile: verified over all 16 `Artifact` calls of a real Design conversation, NOT ONE carries `icon` or `favicon` — the tool's own contract says `icon` is "ignored on an Artifact created from an Artifact type". There was nothing in the input to resolve. Its third source is its TYPE's name ("Design" → 🎨), run through the same table so a future type needs no code. A type nobody named stays faceless rather than guessing. The fallback itself was also inconsistent: 🎨 hard-coded on four surfaces, the kit glyph on the fifth — and 🎨 is exactly what a real Design artifact resolves to, so "we were told nothing" and "this is a design" were indistinguishable. is now the one rendering, drawing the kit's `artifact` mark when there is no emoji. It carries no size class on purpose: `.wf-ico.sm` would tie the per-tile rules on specificity and leave the winner to stylesheet order. Verified: tsc clean, 2289/2289 vitest, `pnpm build` green; in the browser mock the Design demo now shows 🎨 in both the thread cards and the panel, and each tile's generic-mark rule measured at its own size (17/14/15/30px, muted). Co-Authored-By: Claude Opus 5 --- src/features/conversation/ArtifactCard.tsx | 4 +- src/features/conversation/ArtifactRefCard.tsx | 3 +- src/features/conversation/ArtifactViewer.tsx | 9 +-- src/features/conversation/ArtifactsChip.tsx | 3 +- .../ConversationSidePanel.module.css | 7 +++ .../conversation/ConversationSidePanel.tsx | 3 +- .../conversation/artifactIcon.test.ts | 13 +++++ .../{artifactIcon.ts => artifactIcon.tsx} | 55 +++++++++++++++---- src/features/conversation/artifacts.test.ts | 41 ++++++++++++++ src/features/conversation/artifacts.ts | 4 ++ src/ui/conductor-conversation.css | 7 +++ 11 files changed, 129 insertions(+), 20 deletions(-) rename src/features/conversation/{artifactIcon.ts => artifactIcon.tsx} (71%) diff --git a/src/features/conversation/ArtifactCard.tsx b/src/features/conversation/ArtifactCard.tsx index 4c1a8ce7..64c908c2 100644 --- a/src/features/conversation/ArtifactCard.tsx +++ b/src/features/conversation/ArtifactCard.tsx @@ -19,7 +19,7 @@ import { useToolResult } from "../../store/conversationStore"; import { Dot, Ico } from "../../ui/kit"; import { useDisplay } from "../../store/display"; import { artifactHeadline, publishInfo, useArtifacts } from "./artifacts"; -import { artifactFace } from "./artifactIcon"; +import { ArtifactFace, artifactFace } from "./artifactIcon"; import { openArtifactView, routeArtifactOpen } from "./artifactOpen"; import { useHostInert } from "./FileMention"; import { basename } from "./toolMeta"; @@ -128,7 +128,7 @@ export function ArtifactCard({ } > {headline} diff --git a/src/features/conversation/ArtifactRefCard.tsx b/src/features/conversation/ArtifactRefCard.tsx index ba9aeb39..5cfa74af 100644 --- a/src/features/conversation/ArtifactRefCard.tsx +++ b/src/features/conversation/ArtifactRefCard.tsx @@ -10,6 +10,7 @@ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent import { openUrl } from "@tauri-apps/plugin-opener"; import { Ico } from "../../ui/kit"; import { useArtifacts } from "./artifacts"; +import { ArtifactFace } from "./artifactIcon"; import { openArtifactView } from "./artifactOpen"; function childrenText(children: ReactNode): string { @@ -69,7 +70,7 @@ export function ArtifactRefCard({ }} > {title} diff --git a/src/features/conversation/ArtifactViewer.tsx b/src/features/conversation/ArtifactViewer.tsx index cd6b2376..94ff0ada 100644 --- a/src/features/conversation/ArtifactViewer.tsx +++ b/src/features/conversation/ArtifactViewer.tsx @@ -17,6 +17,7 @@ import { useDisplay } from "../../store/display"; import { Ico } from "../../ui/kit"; import type { ArtifactView } from "../editor/editorStore"; import { withArtifactCsp } from "./artifactCsp"; +import { ArtifactFace } from "./artifactIcon"; import { attachArtifactHost, pageHost, @@ -106,7 +107,7 @@ function ArtifactUnavailable({ return (

{headline}

{detail ?

{detail}

: null} @@ -214,7 +215,7 @@ function HostedArtifact({ url, favicon }: { url: string; favicon: string | null {phase === "error" ? (

This artifact’s claude.ai page couldn’t be shown here.

{error ?

{error}

: null} @@ -230,7 +231,7 @@ function HostedArtifact({ url, favicon }: { url: string; favicon: string | null ) : (
{/* Same source as the header — this shows whenever the native view steps aside (a menu, a toast, a folded panel), and it must not call another site "claude.ai". */} @@ -266,7 +267,7 @@ function ViewerHeader({ return (
{view.title} diff --git a/src/features/conversation/ArtifactsChip.tsx b/src/features/conversation/ArtifactsChip.tsx index 44da8988..e5c9f41e 100644 --- a/src/features/conversation/ArtifactsChip.tsx +++ b/src/features/conversation/ArtifactsChip.tsx @@ -14,6 +14,7 @@ import { createPortal } from "react-dom"; import { useDisplay } from "../../store/display"; import { Dot, Ico } from "../../ui/kit"; import { useArtifacts, type Artifact } from "./artifacts"; +import { ArtifactFace } from "./artifactIcon"; import { openArtifactView, routeArtifactOpen } from "./artifactOpen"; /** Fixed placement of the portaled popover, anchored to the chip. */ @@ -97,7 +98,7 @@ function ArtifactRow({ title={url ? (inApp ? "Open in Flight Deck" : "Open in the browser") : "Not published yet"} > {art.title} diff --git a/src/features/conversation/ConversationSidePanel.module.css b/src/features/conversation/ConversationSidePanel.module.css index d0f26417..c3d89220 100644 --- a/src/features/conversation/ConversationSidePanel.module.css +++ b/src/features/conversation/ConversationSidePanel.module.css @@ -545,6 +545,13 @@ font-size: 15px; line-height: 1; } +/* The generic mark (no icon on the publish, no type to name it) — see : it + carries no size class, so each tile states its own. */ +.artTile :global(.wf-ico) { + width: 15px; + height: 15px; + color: var(--wf-tx-lo); +} .artMain { flex: 1; min-width: 0; diff --git a/src/features/conversation/ConversationSidePanel.tsx b/src/features/conversation/ConversationSidePanel.tsx index 277fb732..64ca0176 100644 --- a/src/features/conversation/ConversationSidePanel.tsx +++ b/src/features/conversation/ConversationSidePanel.tsx @@ -36,6 +36,7 @@ import { TaskStatusActions, TaskStatusChip, TaskSubtaskRows } from "../tosse/Tos import { AssigneePicker } from "../tosse/AssigneePicker"; import { TodoList } from "../todos/TodoList"; import { useArtifacts, type Artifact } from "./artifacts"; +import { ArtifactFace } from "./artifactIcon"; import { artifactKind, openArtifactView } from "./artifactOpen"; import { openConversationAt } from "../../store/threadJump"; import { useClearGoalAction } from "./GoalPopover"; @@ -357,7 +358,7 @@ function ArtifactCard({ convId, art }: { convId: string; art: Artifact }) { title={art.url ? "Open in Flight Deck" : "Not published yet"} > {art.title} diff --git a/src/features/conversation/artifactIcon.test.ts b/src/features/conversation/artifactIcon.test.ts index 38a626d8..90eef38a 100644 --- a/src/features/conversation/artifactIcon.test.ts +++ b/src/features/conversation/artifactIcon.test.ts @@ -46,6 +46,19 @@ describe("artifactFace", () => { expect(artifactFace("board", null)).toBe("📋"); }); + it("falls back to the TYPE's name — a typed artifact names no icon at all", () => { + // Verified over all 16 `Artifact` calls of a real Claude Design conversation: not one + // carries `icon` or `favicon` (the tool ignores `icon` for an artifact made from a type). + expect(artifactFace(null, null, "Design")).toBe("🎨"); + expect(artifactFace(null, null, "Slides")).toBe("📽️"); + expect(artifactFace(null, null, "Docs")).toBe("📄"); + }); + + it("prefers the publish's OWN icon over its type", () => { + expect(artifactFace("chart", null, "Design")).toBe("📊"); + expect(artifactFace(null, "🛬", "Design")).toBe("🛬"); + }); + it("says nothing rather than guessing when there is nothing to work with", () => { expect(artifactFace(null, null)).toBeNull(); expect(artifactFace("", " ")).toBeNull(); diff --git a/src/features/conversation/artifactIcon.ts b/src/features/conversation/artifactIcon.tsx similarity index 71% rename from src/features/conversation/artifactIcon.ts rename to src/features/conversation/artifactIcon.tsx index 1b0441b3..1d337a4e 100644 --- a/src/features/conversation/artifactIcon.ts +++ b/src/features/conversation/artifactIcon.tsx @@ -14,7 +14,18 @@ // fallback, which is the very bug being fixed — and a conversation mixing pre- and post-change // publishes renders as ONE list instead of half emoji, half line art. // -// Pure, no React: the resolution is a data question, testable on its own. +// ⚠️ A TYPED artifact (Claude Design…) names NEITHER field — verified over all 16 `Artifact` +// calls of a real Design conversation, and the tool's own contract says `icon` is "ignored on an +// Artifact created from an Artifact type". So the icon wire fix alone still left every Design +// canvas on the blank tile. Its third source is its TYPE's name ("Design" → 🎨), which is +// exactly what that artifact IS — run through the same table, so a future type needs no code. +// +// The resolution is pure and testable on its own; is only the ONE rendering of +// it, so the fallback can no longer differ between the thread, the popover and the panel (it +// did: 🎨 in two of them — which collides with a real Design artifact's face — and the kit glyph +// in the third). + +import { Ico } from "../../ui/kit"; /** Emoji for a word, keyed by a normalised stem (letters only, lowercase). * @@ -245,26 +256,48 @@ function isPictograph(value: string): boolean { return /\p{Extended_Pictographic}/u.test(value); } +/** The emoji a single word resolves to, or null. Normalised to letters first, so `bar-chart`, + * `Bar Chart` and `barchart` agree; looked up whole, then by its longest matching stem. */ +function faceForWord(word: string | null | undefined): string | null { + const w = (word ?? "").toLowerCase().replace(/[^a-z]/g, ""); + if (!w) return null; + const exact = FACES[w]; + if (exact) return exact; + for (const stem of STEMS) if (w.includes(stem)) return FACES[stem]; + return null; +} + /** - * The emoji face for one publish, from its `icon` (word) and/or its `favicon` (legacy emoji), - * or null when neither says anything usable — callers then show their own generic fallback. + * The emoji face for an artifact, from its `icon` (a word) and/or its `favicon` (a legacy + * emoji), falling back to its TYPE's name for a typed artifact — which names neither. Null when + * nothing says anything usable; {@link ArtifactFace} then draws the generic mark. * * An emoji on either field is taken verbatim: a picture the CLI chose beats one we derive. - * A word is normalised to letters (so `bar-chart`, `Bar Chart` and `barchart` agree) and looked - * up whole, then by its longest matching stem. */ export function artifactFace( icon: string | null | undefined, favicon: string | null | undefined, + typeName?: string | null, ): string | null { for (const raw of [favicon, icon]) { const v = raw?.trim(); if (v && isPictograph(v)) return v; } - const word = (icon ?? favicon ?? "").toLowerCase().replace(/[^a-z]/g, ""); - if (!word) return null; - const exact = FACES[word]; - if (exact) return exact; - for (const stem of STEMS) if (word.includes(stem)) return FACES[stem]; - return null; + return faceForWord(icon ?? favicon) ?? faceForWord(typeName); +} + +/** + * The ONE way an artifact's face is drawn: the emoji when there is one, else the kit's + * `artifact` mark. + * + * ⚠️ The generic mark is deliberately NOT an emoji any more. It used to be 🎨 on three surfaces + * — which is also the honest face of a real Claude Design artifact, so "we were told nothing" + * and "this is a design" were indistinguishable. + */ +export function ArtifactFace({ face }: { face: string | null }) { + if (face) return <>{face}; + // ⚠️ No size class on purpose. Each tile sizes it from its OWN rule (`.cv-art-tile .wf-ico` + // and friends), and `.wf-ico.sm` would tie those on specificity — leaving which one wins to + // stylesheet order. Bare, the tile's rule outranks the base one outright. + return ; } diff --git a/src/features/conversation/artifacts.test.ts b/src/features/conversation/artifacts.test.ts index 68b88938..1f658741 100644 --- a/src/features/conversation/artifacts.test.ts +++ b/src/features/conversation/artifacts.test.ts @@ -636,6 +636,47 @@ describe("the artifact icon wire (favicon → icon)", () => { expect(selectArtifacts(e)[0].favicon).toBe("🛬"); }); + it("gives a TYPED artifact its type's face — it names no icon at all", () => { + // ⚠️ Verified over all 16 `Artifact` calls of a real Claude Design conversation: not one + // carries `icon` or `favicon`. The icon-wire fix alone still left every canvas blank. + const type = "https://claude.ai/artifact/QKN21svewxgyPb6SYRqWnd"; + const own = "https://claude.ai/artifact/RERMMCbeNkCTyL7i8WgZrB"; + const e = entryOf( + [ + { + id: "t1", + blocks: [ + tuse("q", { action: "quickstart", intent: "design" }), + tuse("c", { action: "publish", type_url: type, title: "Sidebar layouts" }), + ], + }, + ], + { + q: `Quickstart for a design.\n\n- Design [core] — Design canvas for websites and screens. — type_url: ${type}`, + c: `Created a new Artifact at ${own} from the Artifact type ${type} (release 1789673869-b48e).`, + }, + ); + const a = selectArtifacts(e)[0]; + expect(a.typed).toBe(true); + expect(a.typeName).toBe("Design"); + expect(a.favicon).toBe("🎨"); + }); + + it("leaves a typed artifact faceless rather than guessing when its type is unnamed", () => { + // No quickstart in this conversation → nothing ever named the type. The surfaces then draw + // the generic mark, which must stay distinguishable from a real Design artifact's 🎨. + const type = "https://claude.ai/artifact/QKN21svewxgyPb6SYRqWnd"; + const own = "https://claude.ai/artifact/RERMMCbeNkCTyL7i8WgZrB"; + const e = entryOf( + [{ id: "t1", blocks: [tuse("c", { action: "publish", type_url: type, title: "Canvas" })] }], + { c: `Created a new Artifact at ${own} from the Artifact type ${type}.` }, + ); + const a = selectArtifacts(e)[0]; + expect(a.typed).toBe(true); + expect(a.typeName).toBeNull(); + expect(a.favicon).toBeNull(); + }); + it("keeps last-known-good across a republish that drops the icon", () => { const e = entryOf( [ diff --git a/src/features/conversation/artifacts.ts b/src/features/conversation/artifacts.ts index a24a85fd..257757c2 100644 --- a/src/features/conversation/artifacts.ts +++ b/src/features/conversation/artifacts.ts @@ -435,6 +435,10 @@ export function selectArtifacts(entry: SessionEntry | undefined): Artifact[] { let lastLabel: string | null = null; for (const v of a.versions) if (v.label && v.label.trim()) lastLabel = v.label; a.typeName = a.typeUrl ? typeNames.get(a.typeUrl) ?? null : null; + // ⚠️ A TYPED artifact names NO icon at all (the tool ignores `icon` when the artifact comes + // from a type — verified over every publish of a real Design conversation), so the icon-wire + // fix alone still left it on the blank tile. Its type IS what it is: "Design" → 🎨. + a.favicon ??= artifactFace(null, null, a.typeName); a.title = artifactTitle(titles.get(a) ?? null, lastLabel, a.latestFilePath, { typed: a.typed, typeName: a.typeName, diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index 3a7dba19..9e89e4d3 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -674,6 +674,10 @@ button.cv-agentmsg-name:focus-visible { color:var(--wf-msg); text-decoration:und .cv-art[data-open] { cursor:pointer; } .cv-art[data-open]:hover { border-color:color-mix(in srgb, var(--wf-accent) 45%, var(--wf-line)); background:var(--wf-panel-3, var(--wf-panel)); } .cv-art-tile { flex:none; width:34px; height:34px; display:flex; align-items:center; justify-content:center; font-size:18px; line-height:1; border:1px solid var(--wf-line); border-radius:9px; background:var(--wf-bg-2); } +/* The generic mark, when the publish named no icon and no type says what it is. Sized per + tile rather than by a class on the element: `.wf-ico.sm` would TIE these on specificity and + leave the winner to stylesheet order (see ). */ +.cv-art-tile .wf-ico { width:17px; height:17px; color:var(--wf-tx-lo); } .cv-art-body { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; } .cv-art-title { font-size:12.5px; color:var(--wf-tx-hi); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .cv-art-sub { display:flex; align-items:center; min-width:0; font-size:11px; color:var(--wf-tx-lo); } @@ -747,6 +751,7 @@ button.cv-agentmsg-name:focus-visible { color:var(--wf-msg); text-decoration:und .cv-artpop-open:disabled { cursor:default; opacity:.7; } .cv-artpop-open .wf-ico:last-child { flex:none; width:13px; height:13px; color:var(--wf-tx-lo); } .cv-artpop-fav { flex:none; width:20px; text-align:center; font-size:15px; line-height:1; } +.cv-artpop-fav .wf-ico { width:14px; height:14px; color:var(--wf-tx-lo); vertical-align:-2px; } .cv-artpop-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; } .cv-artpop-title { font-size:12px; color:var(--wf-tx-hi); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .cv-artpop-desc { font-size:11px; color:var(--wf-tx-lo); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -775,6 +780,7 @@ button.cv-agentmsg-name:focus-visible { color:var(--wf-msg); text-decoration:und .cv-artview { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; background:var(--wf-bg); border-left:1px solid var(--wf-line); } .cv-artview-h { flex:none; display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--wf-line); background:var(--wf-panel); } .cv-artview-fav { flex:none; font-size:15px; line-height:1; } +.cv-artview-fav .wf-ico { width:15px; height:15px; color:var(--wf-tx-lo); } .cv-artview-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12.5px; color:var(--wf-tx-hi); } .cv-artview-btn { flex:none; display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; border:0; border-radius:6px; background:transparent; color:var(--wf-tx-lo); cursor:pointer; transition:background .12s ease, color .12s ease; } .cv-artview-btn:hover { background:var(--wf-panel-3, var(--wf-panel)); color:var(--wf-tx-hi); } @@ -803,6 +809,7 @@ button.cv-agentmsg-name:focus-visible { color:var(--wf-msg); text-decoration:und .cv-artview-msg { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:24px; text-align:center; color:var(--wf-tx-lo); font-size:12.5px; } .cv-artview-msg p { margin:0; max-width:34ch; } .cv-artview-msgfav { font-size:28px; opacity:.7; } +.cv-artview-msgfav .wf-ico { width:30px; height:30px; } .cv-artview-open { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border:1px solid var(--wf-line); border-radius:8px; background:var(--wf-panel); color:var(--wf-tx-hi); cursor:pointer; font:inherit; font-size:12px; } .cv-artview-open:hover { border-color:var(--wf-accent); color:var(--wf-accent); } .cv-artview-open .wf-ico { width:14px; height:14px; } From 30171cbefafe4ae7f4d5b82e98c7ea8e8c36aff7 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Mon, 21 Sep 2026 13:26:57 +0200 Subject: [PATCH 05/24] fix(conversation): make the artifact hand-off one movement; add a context section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swap read as two shoves because it WAS two. Measured in the browser: the panel left on `340px→0` at 135ms/ease-out while the artifact arrived on `0→578px` at 190ms/ease-in — opposite curves, different clocks. The conversation's right edge reversed direction mid-flight and overshot its destination by ~200px before settling. `slidePlan` now takes an optional timing, and both halves of a hand-off pass SWAP_TIMING (the entrance's: the arrival sets the pace). Which side needs the override is not symmetric, and the asymmetry is the whole subtlety: - the panel LEAVING and the artifact LEAVING would each pick the short exit; - the panel RETURNING already picks exactly SWAP_TIMING, so it needs nothing. ⚠️ The first attempt detected the hand-off by diffing a ref during render, like `docksChanged`. It silently answered false on the one render that mattered, and the panel went on leaving at 135ms — the measurement caught it, the code review would not have. Both sides now read STATE: the panel reads `convPanelPreempted` (true for as long as it is put away), and the side region reads it through `useFrozenWhile`, since the preemption is lifted in the very `set()` that dismisses the artifact. Verified in the browser mock, sampling the composer's right edge every frame: 0 direction reversals and 0 overshoot in both directions (was 1 and ~200px), and both animations now report 190ms / cubic-bezier(0.32,0.72,0,1). Also: the side panel gains a CONTEXT section — the fill bar, "29.8k / 1M", and the same popover the composer's ring and the Flight Deck meter open (plan usage + « Compact context »), through the same backend-aware hook. It appears once there is a token count, and withholds the bar until the window is known (end of turn) rather than drawing a fill from a guess. Verified: tsc clean, 2293/2293 vitest, `pnpm build` green. Co-Authored-By: Claude Opus 5 --- .../conversation/ConductorConversation.tsx | 23 +++++ .../ConversationSidePanel.module.css | 64 +++++++++++++ .../conversation/ConversationSidePanel.tsx | 96 ++++++++++++++++++- src/ui/kit.tsx | 7 +- src/ui/panelSlide.test.ts | 29 ++++++ src/ui/panelSlide.ts | 31 +++++- src/ui/usePanelSlide.ts | 19 +++- 7 files changed, 257 insertions(+), 12 deletions(-) diff --git a/src/features/conversation/ConductorConversation.tsx b/src/features/conversation/ConductorConversation.tsx index 3072d5fa..5a74b5c5 100644 --- a/src/features/conversation/ConductorConversation.tsx +++ b/src/features/conversation/ConductorConversation.tsx @@ -39,6 +39,7 @@ import { } from "./sidePanelLayout"; import { useDisplay } from "../../store/display"; import { neighborFlex, useFrozenWhile, usePanelSlide } from "../../ui/usePanelSlide"; +import { SWAP_TIMING } from "../../ui/panelSlide"; // Lazy: the Git workspace pulls in Monaco's diff editor + ribbon overlay — its // own chunk, off the startup bundle, fetched only when Git mode is opened. @@ -176,6 +177,16 @@ function ConversationArea({ const docksRef = useRef(docks); const docksChanged = docksRef.current !== docks; docksRef.current = docks; + // The panel is away FOR an artifact → its exit is half of a hand-off, and takes the swap's + // clock instead of the 135ms exit it would pick on its own (see SWAP_TIMING). A plain ⌘I + // close, where nothing takes its place, keeps that exit. + // + // ⚠️ Read as STATE, never as an edge. `preempted` is true for the whole time the panel is + // put away, so there is no render to miss; diffing it against a ref written during render — + // as `docksChanged` does for its own, different question — silently answered `false` here, + // and the panel went on leaving on the wrong clock. Its RETURN needs no override: an opening + // panel already picks exactly SWAP_TIMING. + const preempted = useEditorStore((s) => s.convPanelPreempted); // The width the user dragged the panel to, held inside its own bounds AND inside the room // actually left beside the conversation — a stored 560 on a narrow window renders at the // room available, never by pushing the composer under its floor. @@ -189,6 +200,7 @@ function ConversationArea({ open: want && docks, axis: "x", enabled: !docksChanged, + timing: preempted ? SWAP_TIMING : undefined, restStyle: { flex: `0 0 ${width}px`, minWidth: 0, minHeight: 0, display: "flex" }, }); @@ -281,9 +293,20 @@ function MainArea({ // (splitter + panel) animates its size while the conversation gives way over the same // fraction of a second. `animating` is why the conversation's grow factor is read from it // — see PanelSlide.animating. + // The other half of the hand-off (see ConversationArea): when the conversation panel gave up + // its column for THIS artifact, both slides take the swap's clock, so the row's edge travels + // once instead of lurching each way in turn. + // + // ⚠️ FROZEN while the region closes, for the same reason its CONTENT is (see `shown` below): + // the preemption is lifted in the very `set()` that dismisses the artifact, so reading it + // live would answer "no hand-off" on the one render that decides how the exit is timed. The + // frozen value is the answer from while it was still up. + const preempted = useEditorStore((s) => s.convPanelPreempted); + const swapping = useFrozenWhile(sideOpen, preempted); const slide = usePanelSlide({ open: sideOpen, axis: sideBySide ? "x" : "y", + timing: swapping ? SWAP_TIMING : undefined, restStyle: { flex: `${editorFraction} 1 0`, minWidth: sideBySide ? SIDE_REGION_MIN_PX : 0, diff --git a/src/features/conversation/ConversationSidePanel.module.css b/src/features/conversation/ConversationSidePanel.module.css index c3d89220..bc230482 100644 --- a/src/features/conversation/ConversationSidePanel.module.css +++ b/src/features/conversation/ConversationSidePanel.module.css @@ -325,6 +325,9 @@ /* ---- Todo ------------------------------------------------------------------------------ */ .progress { + /* `display` stated, not inherited from the element: the todo bar is a
but the context + bar is a inside a button, and an inline box ignores `height`. */ + display: block; height: 3px; border-radius: 2px; background: var(--wf-line); @@ -390,6 +393,67 @@ font-weight: 500; } +/* ---- Context ---------------------------------------------------------------------------- */ + +/* The whole reading is the trigger for the shared usage popover (plan + « Compact context »), + so there is no separate affordance to hunt for. */ +.ctxBtn { + display: flex; + flex-direction: column; + gap: 7px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.ctxFill { + display: block; + height: 100%; + border-radius: 2px; + background: var(--wf-accent); + transition: width 0.3s ease; +} +/* Past 70% the bar stops being information and becomes a warning — the same threshold the + composer's ring and the Flight Deck meter turn on. */ +.ctxBtn[data-warn] .ctxFill { + background: var(--wf-att); +} +.ctxRow { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.ctxUsed { + font-size: 12px; + color: var(--wf-tx-hi); + font-variant-numeric: tabular-nums; +} +.ctxBtn[data-warn] .ctxUsed { + color: var(--wf-att); +} +.ctxHint { + margin-left: auto; + font-size: 11px; + color: var(--wf-tx-lo); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ctxBtn:hover .ctxHint { + color: var(--wf-tx); +} + +@media (prefers-reduced-motion: reduce) { + .ctxFill { + transition: none; + } +} + /* ---- Artifacts ------------------------------------------------------------------------- */ .artifacts { diff --git a/src/features/conversation/ConversationSidePanel.tsx b/src/features/conversation/ConversationSidePanel.tsx index 64ca0176..c3aef494 100644 --- a/src/features/conversation/ConversationSidePanel.tsx +++ b/src/features/conversation/ConversationSidePanel.tsx @@ -10,7 +10,7 @@ // Only mounted while the `conversationSidePanel` display pref is on — see ConductorConversation. import { useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; -import { Dot, Ico, TosseCrmMark } from "../../ui/kit"; +import { ContextUsageBody, Dot, Ico, Menu, TosseCrmMark } from "../../ui/kit"; import { CONVERSATION_PANEL_CHORD } from "../../ui/shortcuts"; import { useSetTosseTaskAssignee, @@ -19,6 +19,7 @@ import { useTosseTaskDetail, } from "../../ipc/useTosse"; import { useWorktrees } from "../../ipc/useWorktrees"; +import { useContextData } from "../../store/contextData"; import { useActiveGoal } from "../../store/goalStore"; import { useSessionState, useTodos, useTodoSummary } from "../../store/conversationStore"; import { useConversationRepo, type Conversation } from "../../store/conversationsStore"; @@ -36,6 +37,7 @@ import { TaskStatusActions, TaskStatusChip, TaskSubtaskRows } from "../tosse/Tos import { AssigneePicker } from "../tosse/AssigneePicker"; import { TodoList } from "../todos/TodoList"; import { useArtifacts, type Artifact } from "./artifacts"; +import { useBackendUsage } from "./backendUsage"; import { ArtifactFace } from "./artifactIcon"; import { artifactKind, openArtifactView } from "./artifactOpen"; import { openConversationAt } from "../../store/threadJump"; @@ -49,10 +51,13 @@ export function ConversationSidePanel({ conv }: { conv: Conversation }) { const goal = useActiveGoal(conv.id); const todos = useTodos(conv.id); const artifacts = useArtifacts(conv.id); + // The context section appears once there IS a token count to show — an untouched + // conversation has nothing to report, and "— / —" is not a reading. + const contextKnown = useContextData(conv.id).ctx.usedKnown; // Same gate as the header chip it replaces: a linked task only shows while TOSSE exists. const showTask = tosseAvailable && !!conv.tosseTaskId && !!conv.tosseTaskTitle; - const empty = !showTask && !goal && todos.length === 0 && artifacts.length === 0; + const empty = !showTask && !goal && todos.length === 0 && artifacts.length === 0 && !contextKnown; return (
@@ -263,6 +271,86 @@ function GoalSection({ ); } +/** + * How full this conversation's context window is — the one figure that decides when a + * conversation has to be compacted or forked, and until now readable only from the composer's + * 16px ring. + * + * ⚠️ Two figures that arrive at DIFFERENT times, and the section says which it has: the token + * count lands with the first model call of a turn (and survives a reload), the WINDOW only at + * the end of a turn. Until both are in there is no honest percentage, so the bar is withheld + * and the row reads "— of an unknown window" rather than drawing a fill from a guess. + * + * Clicking opens the SAME popover as the composer's ring and the Flight Deck card's meter + * (plan usage + « Compact context »), through the same backend-aware hook — a third surface + * answering the same question must not be a third implementation of it. + */ +function ContextSection({ convId }: { convId: string }) { + const { ctx, ready, plan } = useContextData(convId); + const usage = useBackendUsage(convId, { enabled: ready }); + const warn = ctx.windowKnown && ctx.pct >= 70; + return ( +
+
+ + Context + {ctx.windowKnown ? `${ctx.pct}%` : "—"} +
+ {/* Portalled: the sections scroll (`.body` is an `overflow-y:auto` box), so an in-flow + popover would be clipped by it — the same reason the Flight Deck's meter portals. */} + + + {ctx.windowKnown ? ( + + ) : null} + + + + {ctx.used} / {ctx.max} + + + {ctx.windowKnown ? "tokens" : "window known at turn end"} + + + + } + > + + +
+ ); +} + function TodoSection({ convId }: { convId: string }) { const todos = useTodos(convId); const summary = useTodoSummary(convId); diff --git a/src/ui/kit.tsx b/src/ui/kit.tsx index 759968d0..8715ca7e 100644 --- a/src/ui/kit.tsx +++ b/src/ui/kit.tsx @@ -1105,9 +1105,10 @@ export interface ContextUsageData { onRefreshUsage?: () => void; } -/** The popover BODY (context window + plan usage + "Compact context"), - * factored out so the ring and the card's clickable meter show an identical panel. */ -function ContextUsageBody({ +/** The popover BODY (context window + plan usage + "Compact context"), factored out so the + * ring, the card's clickable meter and the conversation panel's context section all show an + * identical panel — three surfaces, one answer about the same conversation. */ +export function ContextUsageBody({ ctx, plan, onCompact, diff --git a/src/ui/panelSlide.test.ts b/src/ui/panelSlide.test.ts index b76a1b2b..833fd61b 100644 --- a/src/ui/panelSlide.test.ts +++ b/src/ui/panelSlide.test.ts @@ -4,6 +4,7 @@ import { EASE_SLIDE_OUT, SLIDE_IN_MS, SLIDE_OUT_MS, + SWAP_TIMING, frozenPaneStyle, restingPaneStyle, sizeAlong, @@ -12,6 +13,34 @@ import { slidingSlotStyle, } from "./panelSlide"; +describe("SWAP_TIMING (a hand-off is ONE movement)", () => { + it("gives both halves of a swap the same clock, whichever way each is going", () => { + // ⚠️ The regression it exists for: left to their own directions, the panel LEAVING took + // the 135ms exit and the artifact ARRIVING the 190ms entrance — opposite curves on + // different clocks, so the row's edge lurched one way and then back. Matching numbers on + // both sides IS the contract; asserting on one alone would not have caught it. + const leaving = slidePlan(340, 0, SWAP_TIMING); + const arriving = slidePlan(0, 578, SWAP_TIMING); + expect(leaving?.durationMs).toBe(arriving?.durationMs); + expect(leaving?.easing).toBe(arriving?.easing); + }); + + it("is the ENTRANCE timing — the arrival sets the pace of the exchange", () => { + expect(SWAP_TIMING).toEqual({ durationMs: SLIDE_IN_MS, easing: EASE_SLIDE_IN }); + // Which is why a panel COMING BACK needs no override: it already picks exactly this. + expect(slidePlan(0, 340)).toMatchObject({ durationMs: SLIDE_IN_MS, easing: EASE_SLIDE_IN }); + }); + + it("leaves every other travel on the timing its own direction chose", () => { + expect(slidePlan(340, 0)).toMatchObject({ durationMs: SLIDE_OUT_MS, easing: EASE_SLIDE_OUT }); + }); + + it("still refuses a travel that is not worth playing, override or not", () => { + expect(slidePlan(200, 200, SWAP_TIMING)).toBeNull(); + expect(slidePlan(Number.NaN, 200, SWAP_TIMING)).toBeNull(); + }); +}); + describe("slidePlan", () => { it("opens with the entrance timing and closes with the (shorter) exit one", () => { expect(slidePlan(0, 420)).toEqual({ diff --git a/src/ui/panelSlide.ts b/src/ui/panelSlide.ts index 35b21f08..cc021ccf 100644 --- a/src/ui/panelSlide.ts +++ b/src/ui/panelSlide.ts @@ -59,6 +59,29 @@ export const EASE_SLIDE_OUT = EASE_EXIT; * frame of latency to a panel that is already where it belongs). */ const NEGLIGIBLE_PX = 1; +/** A duration + curve, for a caller that must override what the direction would choose. */ +export interface SlideTiming { + durationMs: number; + easing: string; +} + +/** + * The timing BOTH halves of a HAND-OFF run on — one panel leaving precisely because another + * is arriving in its place (opening an artifact puts the conversation panel away; closing it + * gives the panel back). + * + * ⚠️ Why it has to exist. Two panels are two `usePanelSlide`s, and each picks its timing from + * its OWN direction: the one leaving takes the 135ms exit curve, the one arriving the 190ms + * entrance. Independently that is right; as a swap it is not one movement but two, on + * different clocks and opposite curves — so the region between them lurches one way as the + * first frees its column and back the other way as the second claims it, then waits out the + * 55ms the shorter one has already finished. Given the same clock, the edge travels once. + * + * The ENTRANCE timing, both ways: the panel being revealed is what the user asked for, and it + * is the arrival that should set the pace of the exchange. + */ +export const SWAP_TIMING: SlideTiming = { durationMs: SLIDE_IN_MS, easing: EASE_SLIDE_IN }; + /** A resolved animation: where the slot starts, where it ends, and how it gets there. */ export interface SlidePlan { /** Slot size at the first frame, in CSS px. */ @@ -78,7 +101,7 @@ export interface SlidePlan { * that it would silently drop — leaving the panel stuck at its start size with no * `onfinish` to release it. */ -export function slidePlan(from: number, to: number): SlidePlan | null { +export function slidePlan(from: number, to: number, timing?: SlideTiming): SlidePlan | null { if (!Number.isFinite(from) || !Number.isFinite(to)) return null; const a = Math.max(0, from); const b = Math.max(0, to); @@ -87,8 +110,10 @@ export function slidePlan(from: number, to: number): SlidePlan | null { return { from: a, to: b, - durationMs: opening ? SLIDE_IN_MS : SLIDE_OUT_MS, - easing: opening ? EASE_SLIDE_IN : EASE_SLIDE_OUT, + // `timing` overrides the direction's own choice — see {@link SWAP_TIMING}, the one + // reason to: a hand-off is a single movement and must run on a single clock. + durationMs: timing?.durationMs ?? (opening ? SLIDE_IN_MS : SLIDE_OUT_MS), + easing: timing?.easing ?? (opening ? EASE_SLIDE_IN : EASE_SLIDE_OUT), }; } diff --git a/src/ui/usePanelSlide.ts b/src/ui/usePanelSlide.ts index 8da4269b..7902e4b8 100644 --- a/src/ui/usePanelSlide.ts +++ b/src/ui/usePanelSlide.ts @@ -39,6 +39,7 @@ import { type SlideAxis, type SlideEdge, type SlidePlan, + type SlideTiming, } from "./panelSlide"; type Phase = "closed" | "measure" | "sliding" | "open"; @@ -66,6 +67,14 @@ export interface PanelSlideOptions { /** The slot's style once settled — the parent's own flex/min sizing. Also the style the * slot is measured in, so it must be exactly what the panel rests at. */ restStyle: CSSProperties; + /** + * Override the duration + curve this travel would otherwise pick from its direction. + * + * The ONE caller: a HAND-OFF, where this panel is only leaving (or coming back) because + * another is taking (or giving back) its column — see {@link SWAP_TIMING}. Both halves pass + * it, so the two slides run as one movement instead of on two clocks. + */ + timing?: SlideTiming; /** Set false to switch to instant open/close for this panel while keeping the hook (and * its phase) alive. Used when an ANCESTOR is already animating the same appearance — * a panel sliding inside a sliding panel reads as a stutter, not as depth. Defaults @@ -125,6 +134,7 @@ export function usePanelSlide({ axis, edge = "end", restStyle, + timing, enabled = true, }: PanelSlideOptions): PanelSlide { const [phase, setPhase] = useState(open ? "open" : "closed"); @@ -143,6 +153,11 @@ export function usePanelSlide({ phaseRef.current = phase; const openRef = useRef(open); openRef.current = open; + // Read from the effects rather than declared as a dep: it is an object literal, so a dep + // would re-run the handshake on every render — and it only ever matters at the instant a + // travel is posted, which is exactly when the effects read it. + const timingRef = useRef(timing); + timingRef.current = timing; const stop = useCallback(() => { animRef.current?.cancel(); @@ -182,7 +197,7 @@ export function usePanelSlide({ setPhase("measure"); return; } - const plan = slidePlan(from, 0); + const plan = slidePlan(from, 0, timingRef.current); if (!plan) { setPhase("closed"); return; @@ -194,7 +209,7 @@ export function usePanelSlide({ useLayoutEffect(() => { if (phase !== "measure") return; const to = sizeAlong(slotRef.current, axis); - const plan = slidePlan(fromRef.current, to); + const plan = slidePlan(fromRef.current, to, timingRef.current); if (!plan) { // Nothing worth playing (a zero-size region, or a panel already at its target): // land open rather than animate a pixel. From 58aa5dd34ad22ebf10c534c327fad53d68eb9039 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Mon, 28 Sep 2026 13:12:03 +0200 Subject: [PATCH 06/24] perf(energy): stop idle wakeups and lighten the remote health probe (CRM 2c0ba2b7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the 1 Hz TickEvent thread left over from the scaffold: nothing listened to it, it only woke the app 86,400 times a day. - Run the "Thinking…" accrual ticker only while a session is busy, with a final sweep that seals the open spells when the last one goes idle. - Ambient machine health: probe with `ssh … true` (new `machine_reachability`) instead of the full server diagnosis (a dozen server-side commands, two of them Node.js start-ups) — same ssh options, timeout and failure classification, shared with `diagnose`. - Never probe a machine a live session is attached to: its ssh link already proves reachability; file the attached link as the verdict instead, and record "reachable" on the link's attach edge. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/bootstrap/orchestrator.rs | 134 ++++++++++++++++++-- src-tauri/src/ipc/events.rs | 7 - src-tauri/src/lib.rs | 25 +--- src/features/machines/MachineHealthHost.tsx | 21 ++- src/ipc/bindings.ts | 28 +++- src/ipc/client.ts | 2 +- src/ipc/mock/mockBindings.ts | 16 ++- src/ipc/useGlobalSessionEvents.ts | 44 ++++++- src/store/machineHealth.test.ts | 85 ++++++++++++- src/store/machineHealth.ts | 74 ++++++++++- 10 files changed, 376 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/bootstrap/orchestrator.rs b/src-tauri/src/bootstrap/orchestrator.rs index 94eb0a8b..17ecd208 100644 --- a/src-tauri/src/bootstrap/orchestrator.rs +++ b/src-tauri/src/bootstrap/orchestrator.rs @@ -2015,12 +2015,7 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>) // The `Err` is deliberately discarded — it embeds the raw offending value (see // `validate_ssh_user`/`validate_address_value`'s own docs) and this reason // string is user-facing. Same discipline as `TransportError::InvalidRemoteTarget`. - return ServerDiagnosis { - state: DiagnosisState::Failed { - reason: "this server's saved connection details are not valid — remove and re-add it".to_string(), - }, - ..ServerDiagnosis::unreachable() - }; + return invalid_connection_details(); } cmd.arg(diagnose_script()); // A wedged remote shell (stuck lock, hung `flightdeckd`) must not hang this @@ -2032,6 +2027,29 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>) Ok(Ok(out)) if out.status.success() => { parse_diagnosis(&String::from_utf8_lossy(&out.stdout), true) } + failed => unreachable_after(&machine.host, failed).await, + } +} + +/// The diagnosis of a machine whose saved `user`/`host` no longer pass validation — +/// shared by [`diagnose`] and [`probe_reachability`], so both name it in the same words. +fn invalid_connection_details() -> ServerDiagnosis { + ServerDiagnosis { + state: DiagnosisState::Failed { + reason: "this server's saved connection details are not valid — remove and re-add it".to_string(), + }, + ..ServerDiagnosis::unreachable() + } +} + +/// Classify an ssh round trip that did NOT succeed — shared by [`diagnose`] and +/// [`probe_reachability`], so the ambient probe says WHY in exactly the words the full +/// diagnosis in the server panel uses. +async fn unreachable_after( + host: &str, + failed: Result, tokio::time::error::Elapsed>, +) -> ServerDiagnosis { + match failed { // ssh itself failed (a non-zero exit — OpenSSH's own convention for exit 255, // though anything non-zero here means the same thing for THIS script, which // never returns non-zero on its own). Thread the stderr this used to discard @@ -2042,7 +2060,7 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>) String::from_utf8_lossy(&out.stderr).lines().map(str::to_string).collect(); let issue = ssh_link::classify_transport_close(out.status.code(), &stderr_lines) .unwrap_or(SshLinkIssue::Unreachable); - let tailscale_off = tailscale_off_locally_if_relevant(&machine.host).await; + let tailscale_off = tailscale_off_locally_if_relevant(host).await; ServerDiagnosis::unreachable_with(issue, tailscale_off) } // No `Output` at all: the round trip either couldn't even be spawned, or the @@ -2051,12 +2069,65 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>) // state (a wedged/hung remote shell on a tailnet host is exactly the shape // this clause exists for). Ok(Err(_)) | Err(_) => { - let tailscale_off = tailscale_off_locally_if_relevant(&machine.host).await; + let tailscale_off = tailscale_off_locally_if_relevant(host).await; ServerDiagnosis::unreachable_with(SshLinkIssue::Unreachable, tailscale_off) } } } +/// The one fact the AMBIENT machine-health probe needs: can this Mac reach the server +/// right now, and if not, why. See [`probe_reachability`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct MachineReachability { + pub reachable: bool, + /// The diagnosis's own wording for why, when unreachable — the SAME string a full + /// [`diagnose`] would put in [`DiagnosisState::Failed`]. `None` when reachable. + pub reason: Option, +} + +impl MachineReachability { + fn reached() -> Self { + Self { reachable: true, reason: None } + } +} + +impl From<&ServerDiagnosis> for MachineReachability { + fn from(d: &ServerDiagnosis) -> Self { + let reason = match &d.state { + DiagnosisState::Failed { reason } if !d.reachable => Some(reason.clone()), + _ => None, + }; + Self { reachable: d.reachable, reason } + } +} + +/// "Can I talk to this machine?" — the question the sidebar mark, the Flight Deck lane +/// header and the composer band answer, and NOTHING more. +/// +/// ⚠️ Why not [`diagnose`]: the ambient loop used to run the full diagnosis every 90 s +/// per server, i.e. a fresh ssh handshake PLUS a dozen server-side commands — `systemctl`, +/// `loginctl`, `flightdeckd --version`/`status`, and `claude --version` + `claude auth +/// status`, two Node.js start-ups on the server — to paint a glyph that reads +/// `reachable` and nothing else (see `store/machineHealth.ts`). The server panel still +/// runs [`diagnose`]: it is the one surface that shows the rest. +/// +/// Same ssh options, same timeout, same failure classification as [`diagnose`] (shared +/// through [`unreachable_after`]), so a verdict never depends on which probe produced it. +pub(crate) async fn probe_reachability(machine: &MachineRecord, known_hosts: Option<&str>) -> MachineReachability { + let mut cmd = crate::ipc::commands::keyed_ssh_options(machine.port, machine.identity_file.as_deref(), known_hosts); + cmd.arg("-T"); + if crate::ipc::commands::push_ssh_destination(&mut cmd, &machine.user, &machine.host).is_err() { + return MachineReachability::from(&invalid_connection_details()); + } + // `true`, not an empty command: ssh with no command opens an interactive login + // shell and waits on it. Exit 0 means ssh connected, authenticated and ran it. + cmd.arg("true"); + match tokio::time::timeout(SSH_ROUND_TRIP_TIMEOUT, cmd.output()).await { + Ok(Ok(out)) if out.status.success() => MachineReachability::reached(), + failed => MachineReachability::from(&unreachable_after(&machine.host, failed).await), + } +} + /// [`tailscale::local_status`], gated on [`tailscale::host_looks_like_tailnet`] first /// so a LAN/public server's diagnosis never pays for the subprocess at all — folds /// into the one signal [`ServerDiagnosis::tailscale_off_locally`] ever carries: @@ -2106,6 +2177,16 @@ pub async fn machine_diagnose(app: tauri::AppHandle, machine_id: String) -> Resu Ok(with_bundled_version(&app, diagnose(&machine, known_hosts.as_deref()).await)) } +/// The ambient health probe — see [`probe_reachability`] for why it is not +/// [`machine_diagnose`]. +#[tauri::command] +#[specta::specta] +pub async fn machine_reachability(app: tauri::AppHandle, machine_id: String) -> Result { + let machine = machine_by_id(&app, &machine_id)?; + let known_hosts = known_hosts_path(&app); + Ok(probe_reachability(&machine, known_hosts.as_deref()).await) +} + // ============================================================================ // repair // ============================================================================ @@ -2967,6 +3048,43 @@ mod tests { assert_eq!(not_true.state, DiagnosisState::Failed { reason: "could not reach the server".to_string() }); } + /// The ambient probe must say WHY in the full diagnosis's own words — the mark's + /// tooltip and the server panel can never disagree on the reason. + #[test] + fn reachability_carries_the_diagnosis_reason_verbatim() { + for (issue, tailscale_off) in [ + (SshLinkIssue::KeyRefused, None), + (SshLinkIssue::HostKeyChanged, None), + (SshLinkIssue::Unreachable, None), + (SshLinkIssue::Unreachable, Some(true)), + ] { + let d = ServerDiagnosis::unreachable_with(issue, tailscale_off); + let DiagnosisState::Failed { reason } = &d.state else { panic!("unreachable is Failed") }; + assert_eq!( + MachineReachability::from(&d), + MachineReachability { reachable: false, reason: Some(reason.clone()) }, + ); + } + let invalid = MachineReachability::from(&invalid_connection_details()); + assert!(!invalid.reachable); + assert_eq!( + invalid.reason.as_deref(), + Some("this server's saved connection details are not valid — remove and re-add it"), + ); + assert_eq!(MachineReachability::reached(), MachineReachability { reachable: true, reason: None }); + } + + /// A REACHABLE server whose daemon is stopped is `Failed` too — but it must never + /// read as unreachable through the ambient probe's shape (same prudence as + /// `store/machineHealth.ts`, which only ever looks at `reachable`). + #[test] + fn reachability_of_a_reachable_failed_diagnosis_has_no_reason() { + let mut d = ServerDiagnosis::unreachable(); + d.reachable = true; + d.state = DiagnosisState::Failed { reason: "flightdeckd is not running".to_string() }; + assert_eq!(MachineReachability::from(&d), MachineReachability { reachable: true, reason: None }); + } + #[test] fn plain_unreachable_delegates_to_unreachable_with() { let d = ServerDiagnosis::unreachable(); diff --git a/src-tauri/src/ipc/events.rs b/src-tauri/src/ipc/events.rs index 09ddda5f..5ba7163c 100644 --- a/src-tauri/src/ipc/events.rs +++ b/src-tauri/src/ipc/events.rs @@ -9,13 +9,6 @@ use crate::supervisor::model::{ }; use crate::usage::PlanUsage; -/// Emitted periodically by a Rust timer. Proves Rust -> React (typed event). -#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] -pub struct TickEvent { - pub seq: u32, - pub message: String, -} - /// A session's lifecycle / identity changed. #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] pub struct SessionStateEvent { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eb5ada11..6508269f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,7 +89,8 @@ use ipc::commands::{ }; use bootstrap::connect::bootstrap_forget_host_key; use bootstrap::orchestrator::{ - bootstrap_cancel, bootstrap_resume, bootstrap_server, machine_diagnose, machine_repair, BootstrapSessions, + bootstrap_cancel, bootstrap_resume, bootstrap_server, machine_diagnose, machine_reachability, machine_repair, + BootstrapSessions, ServerLocks, }; use bootstrap::server_setup::{cancel_claude_login, restart_claude_login, start_claude_login, submit_claude_login_code}; @@ -99,7 +100,7 @@ use ipc::events::{ SessionCommandsEvent, SessionExtensionsChangedEvent, SessionMessageEvent, SessionPermissionEvent, SessionPermissionResolvedEvent, SessionRemoteControlEvent, SessionStateEvent, SessionSummaryEvent, SessionTaskEvent, SessionTitleEvent, BootstrapProgressEvent, HostKeyFingerprintEvent, ServerLoginPromptEvent, ServerLoginResultEvent, - TerminalExitEvent, TerminalOutputEvent, TickEvent, + TerminalExitEvent, TerminalOutputEvent, TosseCrmEvent, TosseLiveStateEvent, WakeWordEvent, WorkflowJournalEvent, }; use tauri_specta::{collect_commands, collect_events, Builder, Event}; @@ -388,6 +389,7 @@ fn ipc_builder() -> Builder { bootstrap_resume, bootstrap_cancel, machine_diagnose, + machine_reachability, machine_repair, artifact_host_show, artifact_host_set_bounds, @@ -397,7 +399,6 @@ fn ipc_builder() -> Builder { artifact_host_open_claude_url, ]) .events(collect_events![ - TickEvent, SessionStateEvent, SessionMessageEvent, SessionPermissionEvent, @@ -854,24 +855,6 @@ pub fn run() { let cfg = ipc::commands::load_remote_config(&app.state::()); tauri::async_runtime::spawn(async move { hub.apply_remote(cfg).await }); } - - // Rust timer: emit a TickEvent every second (Rust -> React) — kept as - // a heartbeat / proof of the outbound event leg. - let handle = app.handle().clone(); - std::thread::spawn(move || { - let mut seq = 0u32; - loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - seq += 1; - let ev = TickEvent { - seq, - message: format!("tick #{seq}"), - }; - if TickEvent::emit(&ev, &handle).is_err() { - break; // window closed - } - } - }); Ok(()) }) .build(tauri::generate_context!()) diff --git a/src/features/machines/MachineHealthHost.tsx b/src/features/machines/MachineHealthHost.tsx index c061efb3..25315aeb 100644 --- a/src/features/machines/MachineHealthHost.tsx +++ b/src/features/machines/MachineHealthHost.tsx @@ -14,13 +14,24 @@ // Refresh, and files its result in the SAME store — probing on top of it would // double every round trip precisely when the user is already looking at the answer. // - STAGGERED. Several machines do not all dial out on the same tick. +// - NEVER A MACHINE A SESSION IS ATTACHED TO. Its ssh link already answers keepalives; +// a probe would pay a fresh handshake to learn what the link proves for free. The +// attached link is filed as the verdict instead (see `attachedMachineIds`). +// - REACHABILITY ONLY. The probe is `ssh … true` (`machine_reachability`), not the full +// server diagnosis the Settings panel runs. // // The probe itself (dedup, rate limit, error filing) lives in `store/machineHealth.ts`; // this component only decides WHEN. import { useEffect } from "react"; -import { useMachines, useRepos } from "../../store/conversationsStore"; +import { useConversationsStore, useMachines, useRepos } from "../../store/conversationsStore"; +import { useConversationStore } from "../../store/conversationStore"; import { useSettingsUi } from "../../store/settingsUi"; -import { probeMachine, useMachineHealthStore } from "../../store/machineHealth"; +import { + attachedMachineIds, + probeMachine, + REACHED, + useMachineHealthStore, +} from "../../store/machineHealth"; /** How often a machine in use is re-checked. Generous on purpose: this exists to catch * "the server died while I was working", a thing that happens on the scale of minutes, @@ -77,7 +88,13 @@ export function MachineHealthHost() { // next sweep starts is one this sweep is about to redo, and `probeMachine` dedupes. for (const t of timers) clearTimeout(t); timers.length = 0; + const { conversations, repos: allRepos } = useConversationsStore.getState(); + const attached = attachedMachineIds(conversations, allRepos, useConversationStore.getState().sessions); ids.forEach((id, i) => { + if (attached.has(id)) { + useMachineHealthStore.getState().recordReachability(id, REACHED); + return; + } timers.push( setTimeout(() => { if (!disposed) void probeMachine(id); diff --git a/src/ipc/bindings.ts b/src/ipc/bindings.ts index 9db97fc6..10536d48 100644 --- a/src/ipc/bindings.ts +++ b/src/ipc/bindings.ts @@ -2744,6 +2744,18 @@ async machineDiagnose(machineId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_reachability", { machineId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Claims this machine's [`ServerLocks`] slot (B_lifecycle-#7 review finding) BEFORE * running anything — `Err` with [`server_busy_error`] when a `bootstrap_server`/ @@ -2859,7 +2871,6 @@ sessionTaskEvent: SessionTaskEvent, sessionTitleEvent: SessionTitleEvent, terminalExitEvent: TerminalExitEvent, terminalOutputEvent: TerminalOutputEvent, -tickEvent: TickEvent, tosseCrmEvent: TosseCrmEvent, tosseLiveStateEvent: TosseLiveStateEvent, wakeWordEvent: WakeWordEvent, @@ -2887,7 +2898,6 @@ sessionTaskEvent: "session-task-event", sessionTitleEvent: "session-title-event", terminalExitEvent: "terminal-exit-event", terminalOutputEvent: "terminal-output-event", -tickEvent: "tick-event", tosseCrmEvent: "tosse-crm-event", tosseLiveStateEvent: "tosse-live-state-event", wakeWordEvent: "wake-word-event", @@ -4286,6 +4296,16 @@ owned: boolean } * [`ProvisionState`] plus which machine and when — the row shape Settings lists. */ export type MachineProvisionStatus = { machine_id: string; state: ProvisionState; checked_at_ms: number } +/** + * The one fact the AMBIENT machine-health probe needs: can this Mac reach the server + * right now, and if not, why. See [`probe_reachability`]. + */ +export type MachineReachability = { reachable: boolean; +/** + * The diagnosis's own wording for why, when unreachable — the SAME string a full + * [`diagnose`] would put in [`DiagnosisState::Failed`]. `None` when reachable. + */ +reason: string | null } /** * A remote host (a "server") reached over SSH, on which repos can live and their * conversations run their `claude`. The alpha "machine boundary": Flight Deck owns @@ -5602,10 +5622,6 @@ export type TerminalExitEvent = { id: string } * Keyed by the terminal `id` so the front routes it to the right xterm instance. */ export type TerminalOutputEvent = { id: string; data: string } -/** - * Emitted periodically by a Rust timer. Proves Rust -> React (typed event). - */ -export type TickEvent = { seq: number; message: string } /** * Which of Claude Code's three rule lists a rule sits in. */ diff --git a/src/ipc/client.ts b/src/ipc/client.ts index 645ab625..fc39d54d 100644 --- a/src/ipc/client.ts +++ b/src/ipc/client.ts @@ -88,6 +88,7 @@ export type { RewindOutcome, SearchHit, ServerDiagnosis, + MachineReachability, SshLinkIssue, ServerLoginPromptEvent, ServerLoginResultEvent, @@ -108,7 +109,6 @@ export type { StepId, StepState, StepStatus, - TickEvent, LocalRepoScan, TosseAccountStatus, TosseOffBoardTask, diff --git a/src/ipc/mock/mockBindings.ts b/src/ipc/mock/mockBindings.ts index 3067c7ee..cdefa766 100644 --- a/src/ipc/mock/mockBindings.ts +++ b/src/ipc/mock/mockBindings.ts @@ -22,6 +22,7 @@ import type { LoginResultReason, LoginSession, MachineProvisionStatus, + MachineReachability, MachineRecord, MachineRevokeStatus, RepairAction, @@ -106,7 +107,6 @@ import type { HostBounds, TerminalExitEvent, TerminalOutputEvent, - TickEvent, UsageError, ClientSecret, FolderTree, @@ -199,7 +199,6 @@ const accountLoginEvent = new MockEmitter(); // host's reconnection handling run for real here. const tosseCrmEvent = new MockEmitter(); const tosseLiveStateEvent = new MockEmitter(); -const tickEvent = new MockEmitter(); // No real filesystem in the browser mock — these never fire, but must exist so // the editor's `useFsWatch` can subscribe without crashing. const fsChangeEvent = new MockEmitter(); @@ -246,7 +245,6 @@ export const mockEvents = { accountLoginEvent, tosseCrmEvent, tosseLiveStateEvent, - tickEvent, fsChangeEvent, fsWatchErrorEvent, workflowJournalEvent, @@ -2059,7 +2057,7 @@ export const mockCommands = { if (remoteDemo && mockMachines.length === 0) { const up = findOrCreateMockMachine("vps-ovh", "51.83.1.2", 22, "deploy"); const down = findOrCreateMockMachine("build-box", "10.0.0.5", 22, "ci"); - // The ambient health poll runs `machineDiagnose` against both — seeding their + // The ambient health poll runs `machineReachability` against both — seeding their // diagnoses is what makes the mark's two states appear in the browser build. mockDiagnoses.set(up.id, readyDiagnosis()); mockDiagnoses.set(down.id, { @@ -2414,6 +2412,16 @@ export const mockCommands = { return ok({ ...d }); }, + // Same verdict the full mock diagnosis carries, in the shape the ambient probe gets. + async machineReachability(machineId: string): Promise> { + const d = mockDiagnoses.get(machineId); + if (!d) return err("unknown server"); + return ok({ + reachable: d.reachable, + reason: !d.reachable && d.state.kind === "failed" ? d.state.reason : null, + }); + }, + async machineRepair(machineId: string, action: RepairAction, sudoPassword: string | null): Promise> { const machine = mockMachines.find((m) => m.id === machineId); if (!machine) return err("unknown server"); diff --git a/src/ipc/useGlobalSessionEvents.ts b/src/ipc/useGlobalSessionEvents.ts index 45007af0..9637f33b 100644 --- a/src/ipc/useGlobalSessionEvents.ts +++ b/src/ipc/useGlobalSessionEvents.ts @@ -30,7 +30,7 @@ import type { WorkflowJournalEvent, } from "./client"; import { useConversationStore } from "../store/conversationStore"; -import { probeMachine } from "../store/machineHealth"; +import { probeMachine, REACHED, useMachineHealthStore } from "../store/machineHealth"; import { isGenericThinking } from "../store/activity"; import { useBackgroundTasksStore, @@ -301,16 +301,45 @@ export function useGlobalSessionEvents(): void { // The `state.busy` gate is essential — without it an idle empty session reads as "thinking". // 1 s (not 500 ms) is deliberate: the word only escalates on 40 s tier/rotation boundaries, so // second-resolution is ample and it halves the store churn during otherwise-quiet thinking. + // The ticker only RUNS while at least one session is busy: an idle app has nothing to accrue, + // and a once-a-second wakeup forever is a battery cost for nothing. It is driven by a plain + // store subscription (not a selector) so a busy flip never re-renders the host. When the last + // session goes idle, one final sweep seals every open spell — the seal is the ticker's job + // (`accrueThinking(…, false)`), so stopping without it would leave a spell open. useEffect(() => { - const id = setInterval(() => { + let id: ReturnType | null = null; + const sweep = () => { const now = Date.now(); const store = useConversationStore.getState(); for (const session in store.sessions) { const entry = store.sessions[session]; store.accrueThinking(session, entry.state.busy && isGenericThinking(entry), now); } - }, 1000); - return () => clearInterval(id); + }; + const sync = (sessions: ReturnType["sessions"]) => { + let busy = false; + for (const session in sessions) { + if (sessions[session].state.busy) { + busy = true; + break; + } + } + if (busy && id === null) { + id = setInterval(sweep, 1000); + } else if (!busy && id !== null) { + clearInterval(id); + id = null; + sweep(); + } + }; + sync(useConversationStore.getState().sessions); + const unsubscribe = useConversationStore.subscribe((state, prev) => { + if (state.sessions !== prev.sessions) sync(state.sessions); + }); + return () => { + unsubscribe(); + if (id !== null) clearInterval(id); + }; }, []); useEffect(() => { @@ -494,6 +523,13 @@ export function useGlobalSessionEvents(): void { if (payload.state.link && (!prev || !prev.link)) { const machineId = machineIdForConv(session); if (machineId) void probeMachine(machineId); + } else if (prev?.link && !payload.state.link && !payload.state.ended) { + // …and the opposite edge: the link just ATTACHED (`fd_attach` landed) — an ssh + // round trip that worked. File it, so a mark still red from the outage turns + // back at once: the ambient poll no longer probes a machine a session is attached + // to (see `attachedMachineIds`), so nothing else would. + const machineId = machineIdForConv(session); + if (machineId) useMachineHealthStore.getState().recordReachability(machineId, REACHED); } // Remember the AUTHORITATIVE context window (from the live result's modelUsage) // so the ring is seeded correctly next time this conversation is opened — the diff --git a/src/store/machineHealth.test.ts b/src/store/machineHealth.test.ts index c567a520..8614e721 100644 --- a/src/store/machineHealth.test.ts +++ b/src/store/machineHealth.test.ts @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; -import type { ServerDiagnosis } from "../ipc/client"; +import type { ServerDiagnosis, SessionStatePayload } from "../ipc/client"; import { + attachedMachineIds, healthFromDiagnosis, + healthFromReachability, isUnreachable, + REACHED, resetProbeStateForTests, useMachineHealthStore, type MachineHealth, @@ -180,3 +183,83 @@ describe("the store", () => { expect(useMachineHealthStore.getState().byMachine.m1.probeError).toBeNull(); }); }); + +describe("healthFromReachability", () => { + it("folds a reachability verdict exactly like the full diagnosis it replaces", () => { + const d = unreachable(); + const reason = d.state.kind === "failed" ? d.state.reason : null; + expect(healthFromReachability(undefined, { reachable: false, reason }, 9_000)).toEqual( + healthFromDiagnosis(undefined, d, 9_000), + ); + expect(healthFromReachability(undefined, REACHED, 9_000)).toEqual( + healthFromDiagnosis(undefined, diagnosis(), 9_000), + ); + }); + + it("carries the last time the machine answered across an unreachable verdict", () => { + const up = healthFromReachability(undefined, REACHED, 1_000); + const down = healthFromReachability(up, { reachable: false, reason: "could not reach the server" }, 2_000); + expect(down.lastReachedAtMs).toBe(1_000); + expect(down.reason).toBe("could not reach the server"); + }); + + it("orders reachability verdicts by when the probe was fired, like diagnoses", () => { + resetProbeStateForTests(); + useMachineHealthStore.setState({ byMachine: {} }); + useMachineHealthStore.getState().recordReachability("m1", REACHED, 5_000); + useMachineHealthStore.getState().recordReachability("m1", { reachable: false, reason: "x" }, 1_000); + expect(isUnreachable(useMachineHealthStore.getState().byMachine.m1)).toBe(false); + }); +}); + +describe("attachedMachineIds", () => { + const repos = [ + { id: "r-remote", machineId: "m1" }, + { id: "r-local", machineId: null }, + ]; + const attachedState: SessionStatePayload = { ...neutralState(), session_id: "s", link: null, ended: false }; + + it("lists a machine whose live session is attached", () => { + const ids = attachedMachineIds( + [{ id: "c1", repoId: "r-remote", handle: "session-1" }], + repos, + { c1: { state: attachedState } }, + ); + expect([...ids]).toEqual(["m1"]); + }); + + it("ignores a session that is connecting, reconnecting, ended, not spawned, or local", () => { + const conv = { id: "c1", repoId: "r-remote", handle: "session-1" }; + const none = (state: typeof attachedState, c = conv) => + attachedMachineIds([c], repos, { c1: { state } }).size; + expect(none({ ...attachedState, link: { kind: "connecting" } })).toBe(0); + expect(none({ ...attachedState, link: { kind: "reconnecting", attempt: 2 } })).toBe(0); + expect(none({ ...attachedState, ended: true })).toBe(0); + // The neutral entry a fresh spawn starts from: `link: null` but no session yet. + expect(none({ ...attachedState, session_id: null })).toBe(0); + expect(none(attachedState, { ...conv, handle: null as unknown as string })).toBe(0); + expect(none(attachedState, { ...conv, repoId: "r-local" })).toBe(0); + }); +}); + +/** A session state with every field neutral — the attached/connecting fields are set by each test. */ +function neutralState(): SessionStatePayload { + return { + busy: false, + session_id: null, + cwd: null, + model: null, + permission_mode: null, + output_style: null, + effort: null, + ultracode: false, + activity: null, + awaiting_permission: false, + retry: null, + link: null, + ended: false, + context_tokens: null, + context_window: null, + rate_limit: null, + }; +} diff --git a/src/store/machineHealth.ts b/src/store/machineHealth.ts index ff9a436f..4c20e41e 100644 --- a/src/store/machineHealth.ts +++ b/src/store/machineHealth.ts @@ -27,7 +27,9 @@ // fixes. Same reasoning the remote mark itself already carries. import { create } from "zustand"; import { useShallow } from "zustand/shallow"; -import { commands, type ServerDiagnosis } from "../ipc/client"; +import { commands, type MachineReachability, type ServerDiagnosis } from "../ipc/client"; +import type { Conversation, Repo } from "./conversationsStore"; +import type { SessionEntry } from "./types"; /** What we know about one machine's reachability. Absent from the store = UNKNOWN. */ export interface MachineHealth { @@ -60,12 +62,23 @@ export function healthFromDiagnosis( prev: MachineHealth | undefined, d: ServerDiagnosis, nowMs: number, +): MachineHealth { + return healthFromReachability(prev, { reachable: d.reachable, reason: d.reachable ? null : reasonOf(d) }, nowMs); +} + +/** Fold a reachability-only verdict (the ambient probe, `machine_reachability`) into what + * we already knew. Same fold as {@link healthFromDiagnosis} — the backend builds + * `reason` from the very same classifier, in the very same words. Pure. */ +export function healthFromReachability( + prev: MachineHealth | undefined, + r: MachineReachability, + nowMs: number, ): MachineHealth { return { - reachable: d.reachable, + reachable: r.reachable, checkedAtMs: nowMs, - lastReachedAtMs: d.reachable ? nowMs : (prev?.lastReachedAtMs ?? null), - reason: d.reachable ? null : reasonOf(d), + lastReachedAtMs: r.reachable ? nowMs : (prev?.lastReachedAtMs ?? null), + reason: r.reachable ? null : r.reason, probeError: null, }; } @@ -87,6 +100,9 @@ interface MachineHealthState { * are in flight at once (see `isStaleProbe`). Omit it only where no newer probe can * possibly exist. */ record: (machineId: string, diagnosis: ServerDiagnosis, startedAtMs?: number) => void; + /** Record a reachability-only verdict — the ambient probe's, or the evidence of a live + * session that is attached to the machine right now. Same ordering rule as `record`. */ + recordReachability: (machineId: string, verdict: MachineReachability, startedAtMs?: number) => void; /** A probe that never produced a verdict. Keeps whatever we already knew. */ recordProbeError: (machineId: string, error: string, startedAtMs?: number) => void; /** Drop a machine's row — it was unpaired. */ @@ -130,6 +146,17 @@ export const useMachineHealthStore = create((set) => ({ }, })); }, + recordReachability: (machineId, verdict, startedAtMs) => { + if (isStaleProbe(machineId, startedAtMs)) return; + appliedProbeStartMs.set(machineId, startedAtMs ?? Date.now()); + lastProbeAtMs.set(machineId, Date.now()); + set((s) => ({ + byMachine: { + ...s.byMachine, + [machineId]: healthFromReachability(s.byMachine[machineId], verdict, Date.now()), + }, + })); + }, recordProbeError: (machineId, error, startedAtMs) => set((s) => { // Same ordering rule as a verdict: a stale "we could not ask" must not land on top @@ -182,6 +209,38 @@ export const useUnreachableMachineIds = (): string[] => export const isUnreachable = (h: MachineHealth | undefined): boolean => h !== undefined && h.checkedAtMs > 0 && !h.reachable; +/** The verdict a live, attached session is evidence of. */ +export const REACHED: MachineReachability = { reachable: true, reason: null }; + +/** + * Machines that a live session is ATTACHED to right now — i.e. whose ssh link is up and + * answering its keepalives this very moment (`link === null` once `fd_attach` landed; + * `connecting`/`reconnecting` otherwise). For those, a probe would only re-prove what the + * session already proves, at the price of a fresh ssh handshake: the ambient poll skips + * them and files the attached link as the evidence instead. The moment the link drops, + * its state flips to `reconnecting`, which probes at once (`useGlobalSessionEvents`). + * + * Pure (types only, no store import — `conversationsStore` imports this module). + * `session_id` must be known too: a freshly spawned entry starts from the neutral + * connecting state (`link: null`) before its first real state event, and must not read + * as attached. + */ +export function attachedMachineIds( + conversations: ReadonlyArray>, + repos: ReadonlyArray>, + sessions: Readonly | undefined>>, +): Set { + const out = new Set(); + for (const c of conversations) { + if (!c.handle) continue; + const st = sessions[c.id]?.state; + if (!st || st.ended || st.link != null || st.session_id == null) continue; + const machineId = repos.find((r) => r.id === c.repoId)?.machineId; + if (machineId) out.add(machineId); + } + return out; +} + // --------------------------------------------------------------------------- // Probing // --------------------------------------------------------------------------- @@ -217,8 +276,11 @@ export async function probeMachine(machineId: string, force = false): Promise Date: Mon, 28 Sep 2026 13:17:14 +0200 Subject: [PATCH 07/24] feat(conversation): floating side panel (design B) + an opt-in telemetry deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design B, chosen from the four proposals: the panel becomes a sheet lifted off the window (inset 8px, rounded, shadowed) whose sections are ISLANDS rather than rows ruled apart. The title and its chord share one pill in the title bar's tray voice; counts sit in soft capsules; artifacts are a hairline list; the session footer is the last island, pinned. Same elements as before — only the look moved. The drag splitter goes `quiet` (no rule at rest, accent on hover): a standing line in the gap beside a self-framed sheet was a third edge. SIDE_PANEL_MIN_PX 286 → 296 to count the sheet's insets. Telemetry deck (`conversationTelemetry`, OFF by default, Settings → Display → Thread): Alexandre wanted a loud, "Palantir-like" mode for the fun of watching an agent work — gauges, counters, things that move — on one condition: that it means something. So NOTHING on it moves on its own; every instrument reads a real signal (telemetry.ts): - a context gauge (the plain panel's bar, same popover), needle with overshoot, a sweep circling it only while a turn is in flight; - the run's clock (live, then frozen on the run's length) + a status lamp that names what the agent is doing, in priority order: awaiting permission > retrying > the tool running > streaming > thinking > background ops; - one rolling counter per tool family (read/edit/shell/search/agent/web) with their failures; - calls per 5 s over two minutes — ARRIVALS only, so a conversation reloaded from disk is not drawn as one giant spike at load time; - files touched, turns, API-equivalent cost, model time, background ops, sub-agents; and a feed of the latest calls (running / ok / error). Numbers are memoised on the store references that move when a call lands, not on streamed tokens; the timers are a 1 Hz clock mounted only while a run is live and the 5 s bucket; motion is CSS on transform/opacity and stops under the OS reduce-motion setting. The popover behind the gauge and the bar is now one component (ContextUsageMenu). Verified in the browser mock on the background and workflow demos: counts match the calls, the lamp reads "background ops" with Bg ops = 1 while the background task runs, the clock ticks then freezes on the run's length. tsc clean, 2492/2492 vitest, `pnpm build` green. Co-Authored-By: Claude Opus 5.5 --- .../conversation/ConductorConversation.tsx | 6 +- .../conversation/ContextUsageMenu.tsx | 44 ++ .../ConversationSidePanel.module.css | 305 +++++----- .../conversation/ConversationSidePanel.tsx | 60 +- .../conversation/TelemetryDeck.module.css | 545 ++++++++++++++++++ src/features/conversation/TelemetryDeck.tsx | 327 +++++++++++ src/features/conversation/sidePanelLayout.ts | 11 +- src/features/conversation/telemetry.test.ts | 259 +++++++++ src/features/conversation/telemetry.ts | 374 ++++++++++++ src/features/editor/Splitter.tsx | 7 +- src/features/editor/editor.module.css | 10 + src/features/settings/SettingsPanel.tsx | 21 + src/features/settings/settingsSearch.ts | 1 + src/store/conversationsStore.ts | 6 +- src/store/display.ts | 10 + 15 files changed, 1797 insertions(+), 189 deletions(-) create mode 100644 src/features/conversation/ContextUsageMenu.tsx create mode 100644 src/features/conversation/TelemetryDeck.module.css create mode 100644 src/features/conversation/TelemetryDeck.tsx create mode 100644 src/features/conversation/telemetry.test.ts create mode 100644 src/features/conversation/telemetry.ts diff --git a/src/features/conversation/ConductorConversation.tsx b/src/features/conversation/ConductorConversation.tsx index 5a74b5c5..d589c825 100644 --- a/src/features/conversation/ConductorConversation.tsx +++ b/src/features/conversation/ConductorConversation.tsx @@ -212,9 +212,11 @@ function ConversationArea({ const rect = areaRef.current?.getBoundingClientRect(); if (rect) setConvPanelWidth(rect.right - clientX); }; + // Quiet: the panel is a floating sheet with its own edge, so a standing rule in the gap beside + // it would be a third line between two framed surfaces. It lights up where the pointer finds it. const panel = ( <> - + ); @@ -244,7 +246,7 @@ function ConversationArea({ width, zIndex: 20, display: "flex", - boxShadow: "-18px 0 36px -20px #000", + // No shadow of its own: the sheet inside already floats with one, docked or not. }} > {panel} diff --git a/src/features/conversation/ContextUsageMenu.tsx b/src/features/conversation/ContextUsageMenu.tsx new file mode 100644 index 00000000..14b005de --- /dev/null +++ b/src/features/conversation/ContextUsageMenu.tsx @@ -0,0 +1,44 @@ +// The conversation's context/usage popover — plan usage + « Compact context » — behind any +// trigger. The side panel reaches it from two readings of the SAME number (the plain Context +// section's bar, and the telemetry deck's gauge); both open this, through the same +// backend-aware hook the composer's ring and the Flight Deck card's meter use, so no surface +// answers the question with an implementation of its own. + +import type { ReactElement } from "react"; +import { ContextUsageBody, Menu, type Ctx } from "../../ui/kit"; +import { useContextData } from "../../store/contextData"; +import { useBackendUsage } from "./backendUsage"; + +/** The fill past which a context reading turns into a warning — the threshold the composer's + * ring and the Flight Deck meter also turn on. */ +export const CONTEXT_WARN_PCT = 70; + +export function ContextUsageMenu({ + convId, + trigger, +}: { + convId: string; + /** The trigger, given the current reading and whether it is past the warning threshold. */ + trigger: (ctx: Ctx, warn: boolean) => ReactElement; +}) { + const { ctx, ready, plan } = useContextData(convId); + const usage = useBackendUsage(convId, { enabled: ready }); + const warn = ctx.windowKnown && ctx.pct >= CONTEXT_WARN_PCT; + return ( + // Portalled: the panel's islands scroll (an `overflow-y:auto` body), so an in-flow popover + // would be clipped by it — the same reason the Flight Deck's meter portals. + + + + ); +} diff --git a/src/features/conversation/ConversationSidePanel.module.css b/src/features/conversation/ConversationSidePanel.module.css index bc230482..58ece22f 100644 --- a/src/features/conversation/ConversationSidePanel.module.css +++ b/src/features/conversation/ConversationSidePanel.module.css @@ -1,6 +1,7 @@ -/* The conversation side panel — design "A": a quiet column of sections separated by hairlines, - labels in the small-caps voice the rest of the app uses for section headers, content at - thread size, and a darker Session footer pinned at the bottom. */ +/* The conversation side panel — design "B, floating": a sheet lifted off the app (inset from + the window's edges, rounded, shadowed) whose sections are ISLANDS on it rather than rows + ruled apart. The header is a pill in the title bar's "tray" voice; the session footer is the + last island, pinned. Same elements as the ruled design it replaces — only the look moved. */ .panel { flex: 1; @@ -8,50 +9,56 @@ min-height: 0; display: flex; flex-direction: column; - background: var(--wf-bg-2); - /* No left border: the drag splitter now sits there and draws the divider itself (its ::after - is a 1px line in the same token), so keeping one would paint two rules side by side. */ + /* Floating: the sheet stands off the window's top, right and bottom edges. The LEFT inset + is only 2px because the 6px drag splitter already sits in that gap — together they make + the same 8px as the other three sides. ⚠️ These insets are counted in SIDE_PANEL_MIN_PX + (sidePanelLayout.ts): change one, change the other. */ + margin: 8px 8px 8px 2px; + border: 1px solid var(--wf-line); + border-radius: var(--wf-r-xl); + background: var(--wf-panel); + box-shadow: + 0 24px 56px -24px rgba(0, 0, 0, 0.9), + 0 2px 8px rgba(0, 0, 0, 0.35); + overflow: hidden; } -/* ---- Header: a single tab-like title + close ------------------------------------------- */ +/* ---- Header: the title and its chord in one pill, the close button bare ---------------- */ .head { flex: 0 0 auto; display: flex; align-items: center; - gap: 2px; - height: 40px; - padding: 0 8px; - box-sizing: border-box; - border-bottom: 1px solid var(--wf-line); + gap: 8px; + padding: 10px 10px 8px; } .headTab { display: inline-flex; align-items: center; - height: 28px; - padding: 0 10px; - border-radius: var(--wf-r); - background: var(--wf-panel-2); + gap: 8px; + height: 26px; + padding: 0 4px 0 11px; + box-sizing: border-box; + border: 1px solid var(--wf-line); + border-radius: 9px; + background: var(--wf-bg-2); color: var(--wf-tx-hi); font-size: 12px; font-weight: 500; } -/* The panel's chord, always on show next to the close button. */ +/* The panel's chord, a standing reminder inside the title pill. */ .kbd { - margin-left: auto; - margin-right: 2px; display: inline-flex; align-items: center; height: 18px; padding: 0 5px; - border-radius: 4px; - border: 1px solid var(--wf-line-2); - box-shadow: 0 1px 0 var(--wf-line); - background: var(--wf-panel); - color: var(--wf-tx); + border-radius: 5px; + background: var(--wf-panel-2); + color: var(--wf-tx-lo); font-family: var(--wf-mono); - font-size: 10.5px; + font-size: 10px; + font-weight: 400; line-height: 1; white-space: nowrap; } @@ -63,18 +70,21 @@ width: 26px; height: 26px; border: 0; - border-radius: var(--wf-r); + border-radius: 8px; background: transparent; color: var(--wf-tx-lo); cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } +.head .iconBtn { + margin-left: auto; +} .iconBtn:hover { background: var(--wf-panel-3); color: var(--wf-tx-hi); } -/* ---- Scrolling sections ---------------------------------------------------------------- */ +/* ---- Scrolling islands ------------------------------------------------------------------ */ .body { flex: 1; @@ -83,77 +93,83 @@ overscroll-behavior: contain; display: flex; flex-direction: column; + gap: 8px; + padding: 2px 10px 10px; } +/* One island per section: a raised block, no rule, no border — the gaps do the separating. */ .section { + flex: 0 0 auto; display: flex; flex-direction: column; - gap: 9px; - padding: 14px 16px; - border-bottom: 1px solid color-mix(in srgb, var(--wf-line) 75%, var(--wf-bg-2)); + gap: 8px; + padding: 12px; + border-radius: 11px; + background: var(--wf-panel-2); } .label { display: flex; align-items: center; gap: 7px; - font-size: 10.5px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; + font-size: 11px; color: var(--wf-tx-lo); } .label :global(.wf-ico) { - width: 13px; - height: 13px; + width: 12px; + height: 12px; flex: 0 0 auto; } +/* A count or a reading at the end of a label, in a soft capsule. */ .meta { margin-left: auto; - font-size: 11px; - letter-spacing: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + box-sizing: border-box; + border-radius: 9px; + background: var(--wf-panel-3); + font-size: 10.5px; color: var(--wf-tx); font-variant-numeric: tabular-nums; } +/* The label's action (« Open », « Clear »): a small filled chip, like the island's buttons. */ .textBtn { margin-left: auto; - padding: 2px 6px; - margin-right: -6px; + height: 20px; + padding: 0 8px; border: 0; - border-radius: 5px; - background: transparent; + border-radius: 6px; + background: var(--wf-panel-3); color: var(--wf-tx); font: inherit; - font-size: 11.5px; - font-weight: 500; - letter-spacing: 0; - text-transform: none; + font-size: 11px; cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } .textBtn:hover { - background: var(--wf-panel-3); + background: var(--wf-line-2); color: var(--wf-tx-hi); } -/* A small status pill (TOSSE status, goal state). */ +/* A small status pill (goal state). */ .pill { display: inline-flex; align-items: center; padding: 2px 7px; - border-radius: 5px; + border-radius: 10px; font-size: 11px; - font-weight: 600; - letter-spacing: 0; - text-transform: none; - background: var(--wf-panel-2); + font-weight: 500; + background: var(--wf-panel-3); color: var(--wf-tx); } .label .pill { - padding: 1px 6px; + padding: 1px 7px; font-size: 10.5px; - font-weight: 500; } .pillAtt { background: var(--wf-att-bg); @@ -163,29 +179,22 @@ /* ---- TOSSE task ------------------------------------------------------------------------ */ .tosseMark { - width: 13px; - height: 13px; + width: 12px; + height: 12px; flex: 0 0 auto; color: var(--wf-tosse-accent); } +/* The island IS the card now: the card keeps its job (a click on its background opens the + task) but draws nothing of its own. */ .card { display: flex; flex-direction: column; gap: 8px; - padding: 11px 12px; - border-radius: var(--wf-r-l); - background: var(--wf-panel); - border: 1px solid var(--wf-line); } -/* The task card is clickable as a whole (opens the task); its controls keep their own job. */ .taskCard { - gap: 7px; + gap: 8px; cursor: pointer; - transition: border-color 0.12s ease, background 0.12s ease; -} -.taskCard:hover { - border-color: var(--wf-line-2); } .taskKicker { font-size: 11px; @@ -199,7 +208,7 @@ border: 0; background: transparent; font: inherit; - font-size: 13px; + font-size: 13.5px; font-weight: 600; line-height: 1.4; color: var(--wf-tx-hi); @@ -238,11 +247,15 @@ } /* Subtasks: a disclosure line (« Show subtasks 1/4 ») over the TOSSE view's own tickable - rows, set a size down for the card. */ + rows, sunk into a well a shade darker than the island. */ .subtasks { display: flex; flex-direction: column; - gap: 2px; + gap: 0; + margin-top: 2px; + padding: 4px 10px; + border-radius: 8px; + background: var(--wf-panel); cursor: default; } .subtasksHead { @@ -250,24 +263,23 @@ display: inline-flex; align-items: center; gap: 6px; - padding: 2px 6px 2px 2px; + padding: 4px 6px 4px 2px; margin-left: -2px; border: 0; border-radius: 5px; background: transparent; color: var(--wf-tx-lo); font: inherit; - font-size: 11.5px; + font-size: 11px; cursor: pointer; - transition: background 0.12s ease, color 0.12s ease; + transition: color 0.12s ease; } .subtasksHead:hover { - background: var(--wf-panel-2); color: var(--wf-tx-hi); } .subtasksChev { - width: 11px; - height: 11px; + width: 10px; + height: 10px; transform: rotate(-90deg); transition: transform 0.15s ease; } @@ -285,7 +297,7 @@ .subtasksList button { padding-top: 5px; padding-bottom: 5px; - font-size: 12.5px; + font-size: 12px; } .taskError { font-size: 11.5px; @@ -297,7 +309,7 @@ display: flex; flex-wrap: wrap; gap: 6px; - margin-top: 3px; + margin-top: 2px; cursor: default; } .taskActs:empty { @@ -328,9 +340,10 @@ /* `display` stated, not inherited from the element: the todo bar is a
but the context bar is a inside a button, and an inline box ignores `height`. */ display: block; - height: 3px; + height: 4px; border-radius: 2px; - background: var(--wf-line); + /* A track sunk into the island, the same shade as the subtasks' well. */ + background: var(--wf-panel); overflow: hidden; } .progressFill { @@ -357,20 +370,19 @@ width: 14px; height: 14px; box-sizing: border-box; - border-radius: 4px; + border-radius: 7px; border-width: 1.5px; } .todos :global(.cv-todo-i .cv-check.done) { - background: var(--wf-run-bg); + background: var(--wf-run); border-color: transparent; - color: var(--wf-run); + color: var(--wf-panel); } .todos :global(.cv-todo-i .cv-check.done .wf-ico) { - width: 10px; - height: 10px; + width: 9px; + height: 9px; } .todos :global(.cv-todo-i .cv-check.doing) { - border-radius: 50%; border-style: solid; border-color: var(--wf-accent); animation: none; @@ -413,6 +425,7 @@ .ctxFill { display: block; height: 100%; + min-width: 4px; border-radius: 2px; background: var(--wf-accent); transition: width 0.3s ease; @@ -429,8 +442,8 @@ min-width: 0; } .ctxUsed { - font-size: 12px; - color: var(--wf-tx-hi); + font-size: 11.5px; + color: var(--wf-tx); font-variant-numeric: tabular-nums; } .ctxBtn[data-warn] .ctxUsed { @@ -438,7 +451,7 @@ } .ctxHint { margin-left: auto; - font-size: 11px; + font-size: 10.5px; color: var(--wf-tx-lo); overflow: hidden; text-overflow: ellipsis; @@ -456,10 +469,11 @@ /* ---- Artifacts ------------------------------------------------------------------------- */ +/* A list ruled by hairlines inside the island, not a stack of bordered cards. */ .artifacts { display: flex; flex-direction: column; - gap: 8px; + margin-bottom: -6px; } /* The card is a CONTAINER (open + version badge + the expanded history), not one button: nesting a button inside a button is invalid, and the badge must not open the artifact. */ @@ -467,20 +481,12 @@ display: flex; flex-direction: column; min-width: 0; - border-radius: 8px; - background: var(--wf-panel); - border: 1px solid var(--wf-line); - transition: border-color 0.12s ease; -} -.artCard:hover, -.artCard[data-open] { - border-color: var(--wf-line-2); + border-top: 1px solid var(--wf-line); } .artHead { display: flex; align-items: center; min-width: 0; - padding: 0 6px 0 0; } .artOpen { flex: 1; @@ -488,7 +494,7 @@ align-items: center; gap: 10px; min-width: 0; - padding: 7px 4px 7px 8px; + padding: 7px 4px 7px 0; border: 0; border-radius: 7px; background: transparent; @@ -496,45 +502,46 @@ font: inherit; text-align: left; cursor: pointer; - transition: background 0.12s ease; } -.artOpen:hover { - background: var(--wf-panel-2); +.artOpen:hover .artTitle { + color: var(--wf-tx-hi); } -/* « v3 ⌄ » — the version history's disclosure. A single-version artifact keeps the same - badge slot as static text, so the cards line up whether or not they have a history. */ +/* « v3 › » — the version history's disclosure, a small capsule. A single-version artifact + keeps the same slot as static text, so the rows line up whether or not they have a history. */ .artVbtn, .artV1 { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 2px; - height: 22px; - padding: 0 4px 0 6px; + height: 20px; + padding: 0 5px 0 7px; border: 0; - border-radius: 6px; + border-radius: 10px; background: transparent; color: var(--wf-tx-lo); font: inherit; - font-size: 11px; + font-size: 10.5px; font-variant-numeric: tabular-nums; } .artVbtn { + background: var(--wf-panel-3); + color: var(--wf-tx); cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } .artVbtn:hover { - background: var(--wf-panel-3); + background: var(--wf-line-2); color: var(--wf-tx-hi); } .artV1 { - padding-right: 6px; + padding-right: 7px; color: var(--wf-tx-xlo); } .artVchev { - width: 11px; - height: 11px; + width: 10px; + height: 10px; transform: rotate(-90deg); transition: transform 0.15s ease; } @@ -547,15 +554,17 @@ .artVersions { display: flex; flex-direction: column; - padding: 2px 4px 4px; - border-top: 1px solid color-mix(in srgb, var(--wf-line) 70%, var(--wf-panel)); + margin: 0 0 8px; + padding: 3px; + border-radius: 8px; + background: var(--wf-panel); } .artVrow { display: flex; align-items: center; gap: 8px; min-width: 0; - padding: 5px 6px; + padding: 5px 7px; border: 0; border-radius: 6px; background: transparent; @@ -570,7 +579,7 @@ } .artVn { flex: 0 0 auto; - font-size: 11px; + font-size: 10.5px; color: var(--wf-tx-lo); font-variant-numeric: tabular-nums; } @@ -597,23 +606,23 @@ opacity: 1; } .artTile { - flex: 0 0 28px; - width: 28px; - height: 28px; + flex: 0 0 26px; + width: 26px; + height: 26px; display: inline-flex; align-items: center; justify-content: center; - border-radius: 7px; - background: var(--wf-panel-2); + border-radius: 8px; + background: var(--wf-panel); color: var(--wf-tx); - font-size: 15px; + font-size: 14px; line-height: 1; } /* The generic mark (no icon on the publish, no type to name it) — see : it carries no size class, so each tile states its own. */ .artTile :global(.wf-ico) { - width: 15px; - height: 15px; + width: 14px; + height: 14px; color: var(--wf-tx-lo); } .artMain { @@ -629,9 +638,10 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color 0.12s ease; } .artSub { - font-size: 11px; + font-size: 10.5px; color: var(--wf-tx-lo); } .artGo { @@ -646,29 +656,32 @@ .empty { margin: 0; - padding: 18px 16px; + padding: 14px 6px; font-size: 12px; line-height: 1.55; color: var(--wf-tx-lo); } -/* ---- Session footer -------------------------------------------------------------------- */ +/* ---- Session footer: the last island, pinned ------------------------------------------ */ .foot { flex: 0 0 auto; display: flex; flex-direction: column; - gap: 2px; - padding: 8px 10px; - border-top: 1px solid var(--wf-line); - background: color-mix(in srgb, var(--wf-bg) 70%, var(--wf-bg-2)); + margin: 0 10px 10px; + padding: 4px 10px; + border-radius: 11px; + background: var(--wf-bg-2); } .row { display: flex; align-items: center; gap: 10px; min-height: 38px; - padding: 0 6px; +} +/* The stream and the worktree, told apart by a hairline rather than by two boxes. */ +.row + .row { + border-top: 1px solid color-mix(in srgb, var(--wf-line) 70%, var(--wf-bg-2)); } .rowIco { flex: 0 0 14px; @@ -678,8 +691,8 @@ color: var(--wf-tx); } .rowIco :global(.wf-ico) { - width: 13px; - height: 13px; + width: 12px; + height: 12px; } .rowMain { flex: 1; @@ -696,7 +709,7 @@ white-space: nowrap; } .rowSub { - font-size: 11px; + font-size: 10.5px; color: var(--wf-tx-lo); overflow: hidden; text-overflow: ellipsis; @@ -716,7 +729,7 @@ flex: 0 0 auto; display: flex; align-items: center; - gap: 2px; + gap: 4px; } .rowBtn { display: inline-flex; @@ -725,8 +738,8 @@ width: 26px; height: 26px; border: 0; - border-radius: var(--wf-r); - background: transparent; + border-radius: 8px; + background: var(--wf-panel-2); color: var(--wf-tx); cursor: pointer; transition: background 0.12s ease, color 0.12s ease; @@ -740,19 +753,19 @@ cursor: default; } .smallBtn { - height: 24px; - padding: 0 9px; - border-radius: var(--wf-r); - border: 1px solid var(--wf-line); - background: var(--wf-panel); + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: var(--wf-panel-2); color: var(--wf-tx); font: inherit; - font-size: 11.5px; + font-size: 11px; cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } .smallBtn:hover { - background: var(--wf-panel-2); + background: var(--wf-panel-3); color: var(--wf-tx-hi); } diff --git a/src/features/conversation/ConversationSidePanel.tsx b/src/features/conversation/ConversationSidePanel.tsx index c3aef494..7cdac8b1 100644 --- a/src/features/conversation/ConversationSidePanel.tsx +++ b/src/features/conversation/ConversationSidePanel.tsx @@ -10,7 +10,7 @@ // Only mounted while the `conversationSidePanel` display pref is on — see ConductorConversation. import { useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; -import { ContextUsageBody, Dot, Ico, Menu, TosseCrmMark } from "../../ui/kit"; +import { Dot, Ico, TosseCrmMark } from "../../ui/kit"; import { CONVERSATION_PANEL_CHORD } from "../../ui/shortcuts"; import { useSetTosseTaskAssignee, @@ -37,7 +37,9 @@ import { TaskStatusActions, TaskStatusChip, TaskSubtaskRows } from "../tosse/Tos import { AssigneePicker } from "../tosse/AssigneePicker"; import { TodoList } from "../todos/TodoList"; import { useArtifacts, type Artifact } from "./artifacts"; -import { useBackendUsage } from "./backendUsage"; +import { ContextUsageMenu } from "./ContextUsageMenu"; +import { TelemetryDeck } from "./TelemetryDeck"; +import { useDisplay } from "../../store/display"; import { ArtifactFace } from "./artifactIcon"; import { artifactKind, openArtifactView } from "./artifactOpen"; import { openConversationAt } from "../../store/threadJump"; @@ -54,20 +56,26 @@ export function ConversationSidePanel({ conv }: { conv: Conversation }) { // The context section appears once there IS a token count to show — an untouched // conversation has nothing to report, and "— / —" is not a reading. const contextKnown = useContextData(conv.id).ctx.usedKnown; + // The opt-in telemetry deck (OFF by default): when on, its gauge IS the context reading, so + // the plain Context section steps aside rather than showing the same number twice. + const telemetry = useDisplay((d) => d.conversationTelemetry); // Same gate as the header chip it replaces: a linked task only shows while TOSSE exists. const showTask = tosseAvailable && !!conv.tosseTaskId && !!conv.tosseTaskTitle; - const empty = !showTask && !goal && todos.length === 0 && artifacts.length === 0 && !contextKnown; + const empty = + !telemetry && !showTask && !goal && todos.length === 0 && artifacts.length === 0 && !contextKnown; return (