From 0d885e793b2bc2ca18736179aea51be5f9d4bd15 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Thu, 2 Jul 2026 17:00:34 +0200 Subject: [PATCH 01/91] =?UTF-8?q?feat(sidebar):=20fiabilise=20et=20hi?= =?UTF-8?q?=C3=A9rarchise=20les=20titres=20de=20la=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix du « texte fantôme » sur les noms de conversation : l'auto-title remplaçait le texte en place, WebKit ne repeignait pas la boîte text-overflow:ellipsis (ancien + nouveau glyphes superposés, corrigé seulement par un resize). Le du nom est désormais keyé par conv.name → remonté à chaque changement de titre → zone de peinture propre. Les titres de repos ne changent jamais, d'où l'absence de ghost. - Titres de repos mis en valeur : 15px/700 + léger tracking, chevron de pli plus lumineux, plus d'espacement entre groupes ; noms de conversation posés au niveau de texte normal (--wf-tx), re-brillants au survol / actif / attention → hiérarchie repo > conversation nette. Co-Authored-By: Claude Opus 4.8 --- .../conversation/ConductorSidebar.tsx | 8 ++++++- src/ui/conductor-conversation.css | 24 +++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/features/conversation/ConductorSidebar.tsx b/src/features/conversation/ConductorSidebar.tsx index c960f7a..577c8e2 100644 --- a/src/features/conversation/ConductorSidebar.tsx +++ b/src/features/conversation/ConductorSidebar.tsx @@ -94,7 +94,13 @@ function ConvRow({ conv, active }: { conv: Conversation; active: boolean }) { onDoubleClick={startEdit} > - {conv.name} + {/* Keyed by the name so an incoming auto-title (applyAutoTitle) REMOUNTS this + node instead of mutating its text in place. WebKit fails to repaint a + text-overflow:ellipsis box when only its text content changes at an identical + box size — old and new glyphs superimpose (the "ghost title" that only a + sidebar resize cleared). A fresh node gets a clean paint region. Repo titles + never change, which is why they never ghosted. */} + {conv.name} {isDismissable(status) ? ( diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index a3c3b2b..d6cfb30 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -9,7 +9,7 @@ .cv-side-grip { position:absolute; top:0; right:-3px; bottom:0; width:6px; display:flex; z-index:6; } .cv-search { display:flex; align-items:center; gap:8px; margin:0 10px; padding:7px 9px; background:var(--wf-panel); border:1px solid var(--wf-line); border-radius:var(--wf-r); color:var(--wf-tx-lo); font:inherit; text-align:left; cursor:pointer; transition:background 0.12s ease, border-color 0.12s ease; } .cv-search:hover { background:var(--wf-panel-2); border-color:var(--wf-line-2); color:var(--wf-tx); } -.cv-sess-scroll { flex:1; min-height:0; overflow-y:auto; overscroll-behavior-y:contain; padding:6px 6px 6px; display:flex; flex-direction:column; gap:12px; } +.cv-sess-scroll { flex:1; min-height:0; overflow-y:auto; overscroll-behavior-y:contain; padding:6px 6px 6px; display:flex; flex-direction:column; gap:16px; } .cv-repo { display:flex; flex-direction:column; gap:3px; } /* Single-line repo header: chevron + title (flexes to run full-width up to the always-on +), then the reveal-on-hover tools (worktrees, extensions), then the + pinned at the right. */ @@ -19,11 +19,14 @@ at rest the name runs right up to the + at the edge. */ .cv-repo-title { flex:1; min-width:0; display:flex; align-items:center; gap:6px; border:0; background:transparent; font:inherit; color:inherit; padding:3px 5px; margin:-3px -3px -3px -5px; border-radius:6px; cursor:pointer; text-align:left; } .cv-repo-title:hover { background:var(--wf-panel-2); } -/* The title itself — the important element: normal case (fits more than the old uppercase), - larger and semibold, in the strong text colour, so it clearly headlines the section. */ -.cv-repo-n { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:13.5px; font-weight:600; letter-spacing:0; text-transform:none; color:var(--wf-tx-hi); } -/* Fold chevron — points down when expanded, rotates to point right when collapsed. */ -.cv-repo-fold-chev { flex:0 0 auto; transition:transform .16s ease; } +/* The title itself — the important element: normal case (fits more than the old uppercase). + Deliberately headlines its group: notably larger (15px), bold (700), a touch of tracking, + in the strong text colour, so it clearly outranks the conversation names below it (which + sit dimmer, at --wf-tx). */ +.cv-repo-n { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:15px; font-weight:700; letter-spacing:.2px; text-transform:none; color:var(--wf-tx-hi); } +/* Fold chevron — points down when expanded, rotates to point right when collapsed. Kept a + step brighter than the default header icon so the header unit reads as present, not muted. */ +.cv-repo-fold-chev { flex:0 0 auto; color:var(--wf-tx); transition:transform .16s ease; } .cv-repo.collapsed .cv-repo-fold-chev { transform:rotate(-90deg); } /* Action buttons. The + carries only .cv-repo-act (always shown); worktree/extensions add .cv-repo-reveal (0-width at rest → slide in on header hover, truncating the title). */ @@ -53,7 +56,14 @@ .cv-sess-row[data-attn="error"] { background:var(--wf-err-bg); box-shadow:inset 3px 0 0 var(--wf-err); } .cv-sess-row[data-attn]:hover { filter:brightness(1.12); } .cv-sess { flex:1; min-width:0; display:flex; align-items:center; gap:10px; padding:9px 10px; border:0; background:transparent; font:inherit; color:inherit; text-align:left; cursor:pointer; } -.cv-sess-n { font-size:13px; color:var(--wf-tx-hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1; } +/* Conversation name — sits at the NORMAL text weight/colour so it reads one clear rung + below the bold, brighter repo header above it (see .cv-repo-n). It brightens to the strong + colour when the row is hovered, active, or needs attention, so the one you're looking at + or acting on never feels dimmed. */ +.cv-sess-n { font-size:13px; color:var(--wf-tx); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1; transition:color .12s ease; } +.cv-sess-row:hover .cv-sess-n, +.cv-sess-row.on .cv-sess-n, +.cv-sess-row[data-attn] .cv-sess-n { color:var(--wf-tx-hi); } .cv-sess .wf-badge { margin-left:auto; } /* "Vu": acknowledge a non-blocking reminder (review / error / open-question) to clear it back to idle. Always visible while the row needs attention so it can From f2401c5fb5c317c10509fe1328a8ec9b5f75c9b7 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Thu, 2 Jul 2026 17:03:46 +0200 Subject: [PATCH 02/91] =?UTF-8?q?fix(markdown):=20corrige=20la=20r=C3=A9pa?= =?UTF-8?q?rtition=20des=20largeurs=20de=20colonnes=20de=20tableaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne courte d'un tableau Markdown pouvait être écrasée à ~2 caractères, en-tête cassé en plein mot. Cause : `.root` porte `overflow-wrap: anywhere` (voulu pour wrapper les URLs/chemins longs en prose), propriété héritée par les cellules `th`/`td`. Par spec CSS, `anywhere` (contrairement à `break-word`) compte ses points de coupure dans le calcul du min-content → la largeur minimale d'une cellule s'effondre à ~1 caractère, et `table-layout: auto` écrase alors une colonne courte au profit d'une colonne à contenu long. Fix sur `.root th`/`.root td` (couvre les 3 modes classic/warm/minimal, tous montés sur `.root`) : - `overflow-wrap: break-word; word-break: normal` : une colonne n'est jamais réduite sous la largeur de son mot le plus long, tout en laissant un token pathologiquement long (URL) se couper. - `.root th { white-space: nowrap }` : l'en-tête reste toujours affiché en entier ; le wrapper scrolle horizontalement si besoin (aucune perte). Co-Authored-By: Claude Opus 4.8 --- src/features/conversation/Markdown.module.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/features/conversation/Markdown.module.css b/src/features/conversation/Markdown.module.css index 36a04be..76ff97e 100644 --- a/src/features/conversation/Markdown.module.css +++ b/src/features/conversation/Markdown.module.css @@ -84,10 +84,23 @@ border: 1px solid var(--border); padding: var(--space-2) var(--space-3); text-align: left; + /* Restore a proper per-column min-content floor. `.root` sets + overflow-wrap:anywhere (so long URLs/paths wrap in prose); inherited into + table cells it collapses each column's min-content width to ~1 char, which + lets table-layout:auto crush a short column to ~2 chars and break its + header mid-word. `break-word` wraps a pathological long token WITHOUT + counting those breaks toward min-content, so a column can never be squeezed + below its longest word. */ + overflow-wrap: break-word; + word-break: normal; } .root th { background: var(--bg-surface); font-weight: 600; + /* A header must stay readable in full: keep it on one line. If the table runs + out of room the wrapper (.md-tablewrap / block table) scrolls horizontally, + so headers are never truncated and no data is lost. */ + white-space: nowrap; } /* GFM task lists */ From 5a53b3bbb7e1d96ba0549bbf9d1baeeb428fc6dc Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Thu, 2 Jul 2026 17:15:01 +0200 Subject: [PATCH 03/91] =?UTF-8?q?feat(conversation):=20chip=20d=C3=A9di?= =?UTF-8?q?=C3=A9=20pour=20les=20invocations=20de=20skill=20par=20le=20mod?= =?UTF-8?q?=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un skill auto-invoqué par le modèle (outil Skill, ex. land→done) rendait son body SKILL.md via une carte tool générique. On le rend désormais comme un chip commande dédié « /done » (SkillChip + segment `skill`), réutilisant l'affordance .cv-cmd, cohérent avec un slash-command tapé au composer. Le body du SKILL.md porte isMeta:true et est déjà droppé (assembler L805 + history L245) → jamais de fausse bulle user. On verrouille ce comportement et on documente le wire (trou de spec). - userText.tsx : parseSkillInvocation + SkillChip (dérive /done depuis input.skill) - toolGroup.ts : segment/atome `skill` (casse le run, non compté) - ConductorThread + SubAgentTranscript : branche skill (live + transcript) - CSS .cv-skill (pill dans le flux assistant) - docs/claude-code-protocol.md §3.7.1 : wire skill/slash-command - fixture capture_skill.jsonl + tests régression (Rust) + tests front Co-Authored-By: Claude Opus 4.8 --- docs/claude-code-protocol.md | 35 +++++++++++ src-tauri/src/supervisor/assembler.rs | 60 +++++++++++++++++++ .../supervisor/fixtures/capture_skill.jsonl | 3 + src-tauri/src/supervisor/history.rs | 15 +++++ src/features/conversation/ConductorThread.tsx | 5 +- .../conversation/SubAgentTranscript.tsx | 4 +- src/features/conversation/toolGroup.test.ts | 22 +++++++ src/features/conversation/toolGroup.ts | 16 +++++ src/features/conversation/userText.test.ts | 31 +++++++++- src/features/conversation/userText.tsx | 44 ++++++++++++++ src/ui/conductor-conversation.css | 5 ++ 11 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/supervisor/fixtures/capture_skill.jsonl diff --git a/docs/claude-code-protocol.md b/docs/claude-code-protocol.md index 407df4e..839ea5c 100644 --- a/docs/claude-code-protocol.md +++ b/docs/claude-code-protocol.md @@ -276,6 +276,41 @@ A `tool_result` comes back as a top-level `user` message: May carry `isSynthetic`. **Correlation:** `tool_result.tool_use_id == tool_use.id`. `content` can be a `String` OR an array of content blocks → model as untagged enum. +### 3.7.1 Skill / slash-command invocation (`confirmed` on-disk; spec gap in upstream docs) + +There is **no dedicated wire type** for "a skill/slash-command ran". Both a user-typed `/foo` +and a model-invoked skill expand into ordinary `user` messages (never `tool_result`, never +`system`). The two entry paths differ: + +**User types `/foo` in the composer** → two `user` messages: +1. **Header** — `content` is a **string** opening on the `` wrapper, `isMeta` absent: + ```json + {"type":"user","uuid":"...","message":{"role":"user", + "content":"done\n/done\n"}} + ``` +2. **Body** — `content` is a **text-block array** opening on `Base directory for this skill:`, + flagged **`isMeta:true`**: + ```json + {"type":"user","uuid":"...","isMeta":true,"message":{"role":"user", + "content":[{"type":"text","text":"Base directory for this skill: \n\n# \n<whole SKILL.md body>"}]}} + ``` + +**The MODEL invokes a skill (the `Skill` tool)** → an assistant `tool_use{name:"Skill", +input:{skill,args?}}`, then: +1. a `user` `tool_result` **ack** (`"Launching skill: <skill>"`), `isMeta` absent; +2. the **same `isMeta:true` body** as above. + There is **NO `<command-*>` header** here — the `Skill` tool_use *is* the header. + +**Handling (both live `assembler.rs::ingest_user` and reload `history.rs::push_user`):** an +`isMeta:true` user line is **dropped** — it's injected boilerplate (also covers system-reminders +and the "while you were working" wrapper), never a real turn. So the SKILL.md **body never +renders as a user bubble**. The visible trace is: for a typed command, the header string → +rendered as a clean `.cv-cmd` chip (`userText.tsx`); for a model invocation, the `Skill` +tool_use → rendered as a dedicated command chip (`SkillChip`, from `input.skill`). ⚠️ The +`isMeta` drop is what keeps the body hidden — do NOT surface `isMeta` user lines. Fixture: +`fixtures/capture_skill.jsonl`; regression tests: `skill_body_user_line_is_dropped`, +`skill_body_line_is_skipped_on_restore`, `skill_invocation_fixture_surfaces_tool_use_not_body`. + ### 3.8 `parent_tool_use_id` = sub-agent (Task) grouping (`confirmed`) `parent_tool_use_id` holds the `id` of the `Task` tool_use that spawned a sub-agent; `null` at diff --git a/src-tauri/src/supervisor/assembler.rs b/src-tauri/src/supervisor/assembler.rs index e909725..12c90bd 100644 --- a/src-tauri/src/supervisor/assembler.rs +++ b/src-tauri/src/supervisor/assembler.rs @@ -1188,6 +1188,7 @@ mod tests { use super::*; const CAPTURE: &str = include_str!("fixtures/capture_text.jsonl"); + const CAPTURE_SKILL: &str = include_str!("fixtures/capture_skill.jsonl"); #[test] fn assembles_fixture_into_normalized_events() { @@ -1234,6 +1235,40 @@ mod tests { assert_eq!(ended_idle, Some(false), "session should be idle after the result"); } + /// A model-invoked skill (land → /done) fixture: the `Skill` tool_use IS surfaced (the + /// front renders it as a command chip), while the SKILL.md body — a following `user` line + /// with `isMeta:true` — is dropped, so it never shows as a fake user bubble. The tool_result + /// ack surfaces as a ToolResult (attached to the Skill card), NOT a UserMessage. + #[test] + fn skill_invocation_fixture_surfaces_tool_use_not_body() { + let mut asm = Assembler::new(); + let mut saw_skill_tool_use = false; + let mut user_messages = 0; + for line in CAPTURE_SKILL.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let msg: CliMessage = serde_json::from_str(line).unwrap(); + for ev in asm.ingest(&msg) { + match ev { + SessionEvent::Item(ConversationItem::AssistantMessage { blocks, .. }) => { + saw_skill_tool_use |= blocks + .iter() + .any(|b| matches!(b, NormalizedBlock::ToolUse { name, .. } if name == "Skill")); + } + SessionEvent::Item(ConversationItem::UserMessage { .. }) => user_messages += 1, + _ => {} + } + } + } + assert!(saw_skill_tool_use, "the Skill tool_use must be surfaced (rendered as a chip)"); + assert_eq!( + user_messages, 0, + "neither the tool_result ack nor the isMeta SKILL.md body may surface as a user bubble" + ); + } + #[test] fn normalizes_assistant_tool_use_blocks() { let assistant = serde_json::json!({ @@ -2051,6 +2086,31 @@ mod tests { assert!(asm.ingest(&m).is_empty(), "a meta user line must be dropped"); } + /// REGRESSION (task 2247ebd6): a MODEL-invoked skill (the `Skill` tool — e.g. land → /done) + /// expands its SKILL.md body onto the wire as a `user` line carrying `isMeta:true` (verified + /// on-disk on every model-invoked skill: a `tool_result` ack then a text-block body opening + /// on "Base directory for this skill:"). It MUST be dropped like any meta line — never + /// surfaced as a fake user bubble. The visible trace is the `Skill` tool_use itself (rendered + /// as a command chip by the front), so this redundant body stays hidden. + #[test] + fn skill_body_user_line_is_dropped() { + let mut asm = seeded(); + let m: CliMessage = serde_json::from_value(serde_json::json!({ + "type": "user", + "message": { "role": "user", "content": [ + {"type": "text", + "text": "Base directory for this skill: /x/.claude/skills/done\n\n# Done — Terminer une tâche\n\n…whole SKILL.md body…"} + ]}, + "isMeta": true, + "uuid": "u-skill-body" + })) + .unwrap(); + assert!( + asm.ingest(&m).is_empty(), + "a model-invoked skill's isMeta body must be dropped, never surfaced as a user bubble" + ); + } + // --- Shared helpers for the SendMessage-wake tests ----------------------------------- /// Ingest a main-loop `SendMessage{to}` tool_use and return any `Task` events it emits. fn ingest_send(asm: &mut Assembler, to: &str) -> Vec<BackgroundTask> { diff --git a/src-tauri/src/supervisor/fixtures/capture_skill.jsonl b/src-tauri/src/supervisor/fixtures/capture_skill.jsonl new file mode 100644 index 0000000..9cd714d --- /dev/null +++ b/src-tauri/src/supervisor/fixtures/capture_skill.jsonl @@ -0,0 +1,3 @@ +{"type":"assistant","session_id":"s","uuid":"a1","message":{"id":"msg_1","role":"assistant","content":[{"type":"text","text":"Le travail est fini, je lance /done."},{"type":"tool_use","id":"toolu_skill","name":"Skill","input":{"skill":"tosse-workflow:done"}}]}} +{"type":"user","session_id":"s","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_skill","content":"Launching skill: tosse-workflow:done"}]}} +{"type":"user","session_id":"s","uuid":"u2","isMeta":true,"message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /Users/x/.claude/plugins/cache/tosse-plugins/tosse-workflow/1.1.0/skills/done\n\n# Done — Terminer une tâche\n\nCe skill clôture la tâche en cours et gère les conséquences.\n"}]}} diff --git a/src-tauri/src/supervisor/history.rs b/src-tauri/src/supervisor/history.rs index 1b5438b..064aa3a 100644 --- a/src-tauri/src/supervisor/history.rs +++ b/src-tauri/src/supervisor/history.rs @@ -992,6 +992,21 @@ mod tests { assert_eq!(load_context_fill_in(&base, "does-not-exist"), ContextFill::default()); } + /// REGRESSION (task 2247ebd6): a MODEL-invoked skill's SKILL.md body lands on disk as a + /// `user` line with `isMeta:true` (a text-block array opening on "Base directory for this + /// skill:"). On restore it MUST be skipped — mirroring the live assembler — so a reloaded + /// conversation never shows the body as a fake user bubble. Exercises `push_user` directly. + #[test] + fn skill_body_line_is_skipped_on_restore() { + let entry: Value = serde_json::from_str( + r#"{"type":"user","isMeta":true,"uuid":"m1","message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /x/.claude/skills/done\n\n# Done\n…body…"}]}}"#, + ) + .unwrap(); + let mut items = Vec::new(); + push_user(&entry, &mut items); + assert!(items.is_empty(), "a skill's isMeta body must be skipped on restore"); + } + /// REGRESSION (silent error): a malformed transcript line is tolerated (the good /// turns still restore) BUT no longer vanishes silently — a `history_error` notice /// is appended so the user knows the restored conversation may be incomplete. diff --git a/src/features/conversation/ConductorThread.tsx b/src/features/conversation/ConductorThread.tsx index 2ee7100..ab6ca21 100644 --- a/src/features/conversation/ConductorThread.tsx +++ b/src/features/conversation/ConductorThread.tsx @@ -52,7 +52,7 @@ import { } from "./toolGroup"; import { useEffectiveCleanOutput } from "../../store/display"; import { ClaudeWorkBlock, LiveToolStep, ToolSection } from "./ToolSection"; -import { UserText } from "./userText"; +import { SkillChip, UserText } from "./userText"; import { parseSpecialMessage } from "./specialMessage"; import { SpecialMessageCard } from "./SpecialMessageCard"; import { useShallow } from "zustand/react/shallow"; @@ -537,6 +537,9 @@ function renderSegments( input={seg.step.input} /> ); + if (seg.kind === "skill") + // A model-invoked slash-command renders as a dedicated command chip (never a raw tool row). + return <SkillChip key={seg.key} input={seg.step.input} />; // A `run` of regular tools. The trailing run of the live turn renders EXPANDED so its // steps appear live (spinner → result), then collapses to its header on settle. Past // / non-trailing runs render collapsed. `active` gates the spinner so a resultless diff --git a/src/features/conversation/SubAgentTranscript.tsx b/src/features/conversation/SubAgentTranscript.tsx index 775f45a..8f17e53 100644 --- a/src/features/conversation/SubAgentTranscript.tsx +++ b/src/features/conversation/SubAgentTranscript.tsx @@ -23,7 +23,7 @@ import { type Segment, } from "./toolGroup"; import { ClaudeWorkBlock, StaticToolStep, ToolSection } from "./ToolSection"; -import { UserText } from "./userText"; +import { SkillChip, UserText } from "./userText"; import { parseSpecialMessage } from "./specialMessage"; import { SpecialMessageCard } from "./SpecialMessageCard"; @@ -44,6 +44,8 @@ function renderSegments(segments: Segment[], results: Map<string, JoinedResult>) // so they MUST be handled here — otherwise they fall through to the run branch and crash. if (seg.kind === "agent" || seg.kind === "workflow") return <StaticToolStep key={seg.key} step={seg.step} result={results.get(seg.step.id)} />; + // A model-invoked slash-command: the same dedicated command chip as the live thread. + if (seg.kind === "skill") return <SkillChip key={seg.key} input={seg.step.input} />; // In-band markers only exist in the LIVE thread (interleaveMarkers); a disk transcript // has none, but the union requires the branch — render nothing. if (seg.kind === "marker") return null; diff --git a/src/features/conversation/toolGroup.test.ts b/src/features/conversation/toolGroup.test.ts index 9fa7aef..9a62f49 100644 --- a/src/features/conversation/toolGroup.test.ts +++ b/src/features/conversation/toolGroup.test.ts @@ -84,6 +84,16 @@ describe("groupBlocks", () => { if (segs[1].kind === "agent") expect(segs[1].step.id).toBe("ag"); }); + it("renders a model-invoked Skill as its own segment, breaking the run", () => { + const segs = groupBlocks([ + tool("a", "Read"), + tool("sk", "Skill", { skill: "tosse-workflow:done" }), + tool("b", "Read"), + ]); + expect(segs.map((s) => s.kind)).toEqual(["run", "skill", "run"]); + if (segs[1].kind === "skill") expect(segs[1].step.id).toBe("sk"); + }); + it("hides a detached (background) sub-agent entirely", () => { const segs = groupBlocks([tool("ag", "Agent", { run_in_background: true })]); expect(segs).toEqual([]); @@ -463,6 +473,18 @@ describe("flattenWork / atomsToSegments", () => { expect(a[0].key).toBe("vis-run-0"); expect(b[0].key).toBe("vis-run-0"); // same key though it now holds only the 2nd step }); + + it("keeps a Skill as a non-step atom that breaks a run and round-trips", () => { + const work = segs([tool("a", "Read"), tool("sk", "Skill", { skill: "x:done" }), tool("b", "Edit")]); + const atoms = flattenWork(work); + expect(atoms.map((a) => a.kind)).toEqual(["step", "skill", "step"]); + const back = atomsToSegments(atoms, "vis"); + // The skill breaks the run: the two reads DON'T coalesce across it. + expect(back.map((s) => s.kind)).toEqual(["run", "skill", "run"]); + // A skill is a meta-action, not "work": it never counts as a step nor subscribes as one. + expect(countWorkSteps(work)).toBe(2); + expect(workStepIds(work)).toEqual(["a", "b"]); + }); }); describe("liveVisibleStart", () => { diff --git a/src/features/conversation/toolGroup.ts b/src/features/conversation/toolGroup.ts index 62d8127..2a4ef30 100644 --- a/src/features/conversation/toolGroup.ts +++ b/src/features/conversation/toolGroup.ts @@ -83,6 +83,9 @@ export type Segment = // A `Workflow` renders as its OWN persistent inline card (live overview → post-run report), // like a sub-agent: dedicated segment, breaks the run, never hidden. | { kind: "workflow"; key: string; step: ToolStep } + // A `Skill` (a slash-command the MODEL invoked) renders as a dedicated inline command chip, + // like a user-typed `/foo`: its own segment, breaks the run, never grouped into a step row. + | { kind: "skill"; key: string; step: ToolStep } // An in-band marker (control-change bar / message injected mid-work) shown inline in the // flow. NOT work: it doesn't count as a step and never breaks the clean-output work fold — // it just renders at its chronological place (see coalesceCleanRounds / interleaveMarkers). @@ -159,6 +162,13 @@ export function groupBlocks( out.push({ kind: "workflow", key: `w-${i}`, step: { id: b.id, name: b.name, input: b.input } }); return; } + // A Skill (model-invoked slash-command) is its own inline command chip — breaks the run + // so the invocation stands out instead of hiding in an "Exécuté N étapes" step row. + if (b.name === "Skill") { + run = null; + out.push({ kind: "skill", key: `sk-${i}`, step: { id: b.id, name: b.name, input: b.input } }); + return; + } if (!run) { run = []; out.push({ kind: "run", key: `run-${i}`, steps: run }); @@ -220,6 +230,9 @@ export type WorkAtom = | { kind: "step"; key: string; step: ToolStep } | { kind: "agent"; key: string; step: ToolStep } | { kind: "workflow"; key: string; step: ToolStep } + // A skill chip: a non-step atom (like text/thinking) — it folds with the surrounding work + // but never counts as a step nor holds the live window open (its ack settles instantly). + | { kind: "skill"; key: string; step: ToolStep } | { kind: "thinking"; key: string; text: string } | { kind: "text"; key: string; text: string } // An in-band marker: a non-step atom (like text/thinking) — it folds with the surrounding @@ -236,6 +249,8 @@ export function flattenWork(segs: Segment[]): WorkAtom[] { out.push({ kind: "agent", key: seg.key, step: seg.step }); } else if (seg.kind === "workflow") { out.push({ kind: "workflow", key: seg.key, step: seg.step }); + } else if (seg.kind === "skill") { + out.push({ kind: "skill", key: seg.key, step: seg.step }); } else if (seg.kind === "thinking") { out.push({ kind: "thinking", key: seg.key, text: seg.text }); } else if (seg.kind === "marker") { @@ -266,6 +281,7 @@ export function atomsToSegments(atoms: WorkAtom[], keyPrefix: string): Segment[] run = null; if (a.kind === "agent") out.push({ kind: "agent", key: a.key, step: a.step }); else if (a.kind === "workflow") out.push({ kind: "workflow", key: a.key, step: a.step }); + else if (a.kind === "skill") out.push({ kind: "skill", key: a.key, step: a.step }); else if (a.kind === "thinking") out.push({ kind: "thinking", key: a.key, text: a.text }); else if (a.kind === "marker") out.push({ kind: "marker", key: a.key, markerKind: a.markerKind, id: a.id }); diff --git a/src/features/conversation/userText.test.ts b/src/features/conversation/userText.test.ts index 6d89a42..1cf4677 100644 --- a/src/features/conversation/userText.test.ts +++ b/src/features/conversation/userText.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseSlashCommand } from "./userText"; +import { parseSkillInvocation, parseSlashCommand } from "./userText"; describe("parseSlashCommand", () => { it("extracts a bare command (with and without args)", () => { @@ -28,3 +28,32 @@ describe("parseSlashCommand", () => { expect(parseSlashCommand("")).toBeNull(); }); }); + +describe("parseSkillInvocation", () => { + it("drops a plugin namespace to the short command", () => { + expect(parseSkillInvocation({ skill: "tosse-workflow:done" })).toEqual({ + command: "/done", + qualified: "tosse-workflow:done", + args: "", + }); + }); + + it("keeps a bare project skill as-is and carries args", () => { + expect(parseSkillInvocation({ skill: "code-review", args: "20" })).toEqual({ + command: "/code-review", + qualified: "code-review", + args: "20", + }); + expect(parseSkillInvocation({ skill: "start" })).toEqual({ + command: "/start", + qualified: "start", + args: "", + }); + }); + + it("returns null when there is no skill field", () => { + expect(parseSkillInvocation({})).toBeNull(); + expect(parseSkillInvocation({ skill: " " })).toBeNull(); + expect(parseSkillInvocation("not an object" as never)).toBeNull(); + }); +}); diff --git a/src/features/conversation/userText.tsx b/src/features/conversation/userText.tsx index 1fd8629..cb9c130 100644 --- a/src/features/conversation/userText.tsx +++ b/src/features/conversation/userText.tsx @@ -3,6 +3,13 @@ // <command-args>…</command-args>` — raw noise the user shouldn't read. We surface it as a // clean command chip instead. Shared by the live thread (MsgUser) and the disk transcript // (SubAgentTranscript) so both render commands identically. +// +// A skill the MODEL invokes goes through a different wire shape: a `Skill` tool_use (not a +// user turn), so it's surfaced by a sister renderer (SkillChip) off the tool input — see +// parseSkillInvocation. Both funnel into the same `.cv-cmd` affordance so "/done" reads +// identically whether the human typed it or the agent invoked it. +import type { JsonValue } from "../../ipc/client"; +import { field } from "../../agent/ask"; import { Ico } from "../../ui/kit"; const NAME_RE = /<command-name>([\s\S]*?)<\/command-name>/; @@ -34,3 +41,40 @@ export function UserText({ text }: { text: string }) { </span> ); } + +/** Read a model-invoked skill's command out of a `Skill` tool_use input. The `skill` field is + * the fully-qualified id the model called (`tosse-workflow:done`, `start`, `code-review`); we + * present it as the command a human would type — drop any `plugin:` namespace (everything up + * to and including the last `:`) and prefix a slash: `tosse-workflow:done` → `/done`, + * `start` → `/start`. `qualified` keeps the full id for a disambiguating tooltip. `null` when + * the input carries no skill. */ +export function parseSkillInvocation( + input: JsonValue, +): { command: string; qualified: string; args: string } | null { + const skill = field(input, "skill"); + if (!skill || !skill.trim()) return null; + const qualified = skill.trim(); + const short = qualified.slice(qualified.lastIndexOf(":") + 1); + const args = (field(input, "args") ?? "").trim(); + return { command: "/" + short, qualified, args }; +} + +/** A skill the model invoked (the `Skill` tool), shown as a dedicated command chip — the SAME + * `.cv-cmd` affordance as a user-typed slash-command, so the agent's `/done` reads like a + * command, never a raw tool card. Shared by the live thread and the disk transcript. */ +export function SkillChip({ input }: { input: JsonValue }) { + const inv = parseSkillInvocation(input); + if (!inv) return null; + // Show the qualified id on hover only when it adds information (a plugin command whose short + // form hides its namespace); a bare project skill's tooltip would just echo the chip. + const title = "/" + inv.qualified !== inv.command ? inv.qualified : undefined; + return ( + <span className="cv-skill"> + <span className="cv-cmd" title={title}> + <Ico name="wand" className="sm" /> + <span className="cv-cmd-name">{inv.command}</span> + {inv.args ? <span className="cv-cmd-args">{inv.args}</span> : null} + </span> + </span> + ); +} diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index a3c3b2b..e78bc2b 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -128,6 +128,11 @@ .cv-cmd .wf-ico { color:var(--wf-accent); flex:none; } .cv-cmd-name { color:var(--wf-tx-hi); font-weight:600; } .cv-cmd-args { color:var(--wf-tx-lo); overflow-wrap:anywhere; } +/* A skill/command the MODEL invoked (the `Skill` tool, e.g. land → /done), shown inline in the + assistant flow as its own command pill — distinct from a user-typed chip (which sits inside a + user bubble). Reuses the `.cv-cmd` chip inside so "/done" reads identically either way. */ +.cv-skill { display:flex; } +.cv-skill .cv-cmd { width:fit-content; max-width:100%; padding:5px 10px; border:1px solid var(--wf-line); border-radius:8px; background:var(--wf-panel-2); } /* Queued user message (sent while the agent was busy): dashed wait-coloured border + an "en attente" tag, so it reads clearly as not-yet-processed. The tag clears once the agent picks the message up (next message_started; see conversationStore). */ From 04112590bd55ffe58f26ceb01c320a356b1c8f7f Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 17:54:08 +0200 Subject: [PATCH 04/91] =?UTF-8?q?feat(plan):=20rendu=20d=C3=A9di=C3=A9=20d?= =?UTF-8?q?u=20mode=20plan=20(ExitPlanMode)=20+=20d=C3=A9cision=20+=20anno?= =?UTF-8?q?tations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carte « Plan proposé » de premier plan (segment `plan` extrait du pli clean-output), qui rend le plan markdown et héberge la décision : accepter (→ bascule le mode en `auto`) ou refuser & réviser. L'utilisateur peut surligner des passages et les commenter (CSS Custom Highlight API) ; les retours sont renvoyés à l'agent dans le message de refus. Badge de statut (accepté/refusé) dérivé du tool_result, persistant au reload. Annotations + note générale persistées (localStorage), restaurées à l'undo d'une suppression de conversation. 100% front : le wire ExitPlanMode passe déjà par le pipeline tool_use + can_use_tool + tool_result. Aucun changement Rust. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/features/conversation/ConductorThread.tsx | 24 +- src/features/conversation/PlanCard.tsx | 533 ++++++++++++++++++ .../conversation/SubAgentTranscript.tsx | 9 +- src/features/conversation/ToolSection.tsx | 16 +- src/features/conversation/planStatus.test.ts | 66 +++ src/features/conversation/planStatus.ts | 77 +++ src/features/conversation/toolGroup.test.ts | 47 ++ src/features/conversation/toolGroup.ts | 29 +- src/features/conversation/toolMeta.ts | 10 + src/store/conversationsStore.ts | 25 +- src/store/planAnnotations.test.ts | 104 ++++ src/store/planAnnotations.ts | 196 +++++++ src/ui/conductor-conversation.css | 63 ++- 13 files changed, 1186 insertions(+), 13 deletions(-) create mode 100644 src/features/conversation/PlanCard.tsx create mode 100644 src/features/conversation/planStatus.test.ts create mode 100644 src/features/conversation/planStatus.ts create mode 100644 src/store/planAnnotations.test.ts create mode 100644 src/store/planAnnotations.ts diff --git a/src/features/conversation/ConductorThread.tsx b/src/features/conversation/ConductorThread.tsx index 2ee7100..c0fb2dc 100644 --- a/src/features/conversation/ConductorThread.tsx +++ b/src/features/conversation/ConductorThread.tsx @@ -32,6 +32,7 @@ import { fmtDuration, isBackgroundAgentInput, shortModel } from "../../agent/sub import { fmtTokens } from "../../store/contextData"; import { Avatar, ClaudeMark, Dot, Ico, UserMark, type StreamState } from "../../ui/kit"; import { QuestionnaireAsk } from "./QuestionnaireAsk"; +import { PlanCard } from "./PlanCard"; import { StreamMarkdown } from "./StreamMarkdown"; import { SubAgentTranscript } from "./SubAgentTranscript"; import { ThinkingBlock } from "./ThinkingBlock"; @@ -537,6 +538,17 @@ function renderSegments( input={seg.step.input} /> ); + if (seg.kind === "plan") + // A proposed plan (ExitPlanMode) renders as its own prominent card: the plan markdown, + // its accept/reject decision (hosted here, not in the bottom AskTurn), and annotations. + return ( + <PlanCard + key={seg.key} + session={session} + toolUseId={seg.step.id} + input={seg.step.input} + /> + ); // A `run` of regular tools. The trailing run of the live turn renders EXPANDED so its // steps appear live (spinner → result), then collapses to its header on settle. Past // / non-trailing runs render collapsed. `active` gates the spinner so a resultless @@ -907,9 +919,15 @@ export function ConductorThread({ return <TurnResultRow key={item.id} session={session} resultId={item.id} />; return null; })} - {pending.map((req) => ( - <AskTurn key={req.request_id} session={session} request={req} /> - ))} + {pending + // ExitPlanMode's approval is hosted ON its plan card (see PlanCard) — which + // finds this pending request by tool_use_id — so it must NOT also appear as a + // generic yes/no AskTurn at the bottom. A pending plan is always the trailing + // block (the agent pauses right after proposing it), so its card is on screen. + .filter((req) => req.tool_name !== "ExitPlanMode") + .map((req) => ( + <AskTurn key={req.request_id} session={session} request={req} /> + ))} {busy && !awaiting && <WorkingIndicator session={session} />} </> )} diff --git a/src/features/conversation/PlanCard.tsx b/src/features/conversation/PlanCard.tsx new file mode 100644 index 0000000..fb785ad --- /dev/null +++ b/src/features/conversation/PlanCard.tsx @@ -0,0 +1,533 @@ +// The proposed-plan card (ExitPlanMode). Renders the agent's plan markdown prominently and, +// while the plan awaits approval, hosts the decision directly on the card: accept it, or +// reject-and-revise with feedback. The user can select any passage of the plan to attach a +// comment; those comments are highlighted inline (CSS Custom Highlight API) and, on "reject & +// revise", bundled into the deny message the agent reads (see buildRejectionMessage) so it +// reworks the plan. Once resolved, the card shows a persistent Accepté/Refusé badge derived +// from the tool_result (so a reloaded/historical plan reads correctly). +// +// It replaces the generic tool card AND the generic permission prompt for ExitPlanMode: the +// tool is pulled out as its own `plan` segment (see toolGroup) and the matching permission is +// suppressed from the bottom AskTurn list (see ConductorThread) — this card owns both. + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { JsonValue } from "../../ipc/client"; +import { field } from "../../agent/ask"; +import { useAnswerPermission } from "../../ipc/useCommands"; +import { usePendingPermissions, useToolResult } from "../../store/conversationStore"; +import { useConversationsStore } from "../../store/conversationsStore"; +import { + newAnnotationId, + usePlanAnnotations, + usePlanAnnotationsStore, + usePlanNote, + type PlanAnnotation, +} from "../../store/planAnnotations"; +import { Ico } from "../../ui/kit"; +import { StreamMarkdown } from "./StreamMarkdown"; +import { buildRejectionMessage, planResultDecision } from "./planStatus"; + +// ---- CSS Custom Highlight API registry ------------------------------------------------ +// All plan cards share ONE named highlight; each card contributes its ranges through a +// provider so several cards never clobber each other under the same name. Ranges are rebuilt +// from stored character offsets on every publish (the plan markdown is immutable, so offsets +// stay valid), which also sidesteps stale-DOM ranges across re-renders. Degrades to no visual +// highlight (annotations still listed) where the API is unavailable. +// Two layers: SAVED comments (solid) and the IN-PROGRESS draft being commented (dashed), so +// the passage you're annotating right now reads differently from ones already commented. +const HL_SAVED = "tosse-plan-annotation"; +const HL_DRAFT = "tosse-plan-draft"; +interface RangeSet { + saved: Range[]; + draft: Range[]; +} +type RangeProvider = () => RangeSet; +const providers = new Map<string, RangeProvider>(); + +interface HighlightCtor { + new (...ranges: Range[]): unknown; +} +interface HighlightRegistry { + set(name: string, value: unknown): void; + delete(name: string): void; +} + +function highlightApi(): { Ctor: HighlightCtor; reg: HighlightRegistry } | null { + const Ctor = (globalThis as { Highlight?: HighlightCtor }).Highlight; + const reg = (CSS as unknown as { highlights?: HighlightRegistry }).highlights; + if (!Ctor || !reg) return null; + return { Ctor, reg }; +} + +function publishLayer( + api: { Ctor: HighlightCtor; reg: HighlightRegistry }, + name: string, + ranges: Range[], +): void { + if (ranges.length === 0) api.reg.delete(name); + else api.reg.set(name, new api.Ctor(...ranges)); +} + +function republishHighlights(): void { + const api = highlightApi(); + if (!api) return; + const saved: Range[] = []; + const draft: Range[] = []; + for (const p of providers.values()) { + try { + const rs = p(); + saved.push(...rs.saved); + draft.push(...rs.draft); + } catch { + /* a stale range from an unmounting card — skip it */ + } + } + publishLayer(api, HL_SAVED, saved); + publishLayer(api, HL_DRAFT, draft); +} + +// ---- DOM offset helpers --------------------------------------------------------------- + +/** Character offsets [start, end) of a selection range within `root`'s text, or null if the + * selection is empty. Measured against the same text stream that {@link rangeFromOffsets} + * walks, so a stored offset round-trips back to the same range. */ +function selectionOffsets(root: HTMLElement, range: Range): { start: number; end: number } | null { + const pre = range.cloneRange(); + pre.selectNodeContents(root); + pre.setEnd(range.startContainer, range.startOffset); + const start = pre.toString().length; + const len = range.toString().length; + if (len === 0) return null; + return { start, end: start + len }; +} + +/** Rebuild a DOM Range from character offsets by walking `root`'s text nodes. */ +function rangeFromOffsets(root: Node, start: number, end: number): Range | null { + if (start >= end) return null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let acc = 0; + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + let node = walker.nextNode() as Text | null; + while (node) { + const len = node.data.length; + if (!startNode && acc + len > start) { + startNode = node; + startOffset = start - acc; + } + if (acc + len >= end) { + endNode = node; + endOffset = end - acc; + break; + } + acc += len; + node = walker.nextNode() as Text | null; + } + if (!startNode || !endNode) return null; + try { + const r = document.createRange(); + r.setStart(startNode, startOffset); + r.setEnd(endNode, endOffset); + return r; + } catch { + return null; + } +} + +// ---- Component ------------------------------------------------------------------------ + +interface Draft { + start: number; + end: number; + quote: string; +} + +// The floating composer's approximate size, used to keep it on-screen (clamp/flip). Exact +// pixels don't matter — it just needs to be close enough to decide "does it overflow". +const COMPOSER_W = 300; +const COMPOSER_H = 150; +const VIEWPORT_MARGIN = 8; + +type Decision = "pending" | "approved" | "rejected" | "none"; + +export function PlanCard({ + session, + toolUseId, + input, +}: { + session: string; + toolUseId: string; + input: JsonValue; +}) { + const plan = field(input, "plan") ?? ""; + const pending = usePendingPermissions(session).find((p) => p.tool_use_id === toolUseId); + const result = useToolResult(session, toolUseId); + const annotations = usePlanAnnotations(session, toolUseId); + const answer = useAnswerPermission(session); + const addAnn = usePlanAnnotationsStore((s) => s.add); + const removeAnn = usePlanAnnotationsStore((s) => s.remove); + + const decision: Decision = pending + ? "pending" + : result + ? mapResult(planResultDecision(result.content, result.isError)) + : "none"; + const interactive = decision === "pending"; + + const note = usePlanNote(session, toolUseId); + const setNote = usePlanAnnotationsStore((s) => s.setNote); + + const bodyRef = useRef<HTMLDivElement | null>(null); + const [draft, setDraft] = useState<Draft | null>(null); + // The composer textarea is CONTROLLED (reset per selection) so a re-selection can never carry + // the previous passage's typed text onto the new passage's quote. + const [draftText, setDraftText] = useState(""); + // The composer's viewport anchor, recomputed from the live selection range (so it follows the + // passage on scroll/resize) and clamped/flipped to stay on-screen. + const [pos, setPos] = useState<{ left: number; top: number } | null>(null); + + // Keep the highlight provider reading the latest annotations + draft via refs, so we register + // once per card and just republish when they change. + const annRef = useRef(annotations); + annRef.current = annotations; + const draftRef = useRef(draft); + draftRef.current = draft; + + useLayoutEffect(() => { + const key = `${session}:${toolUseId}`; + providers.set(key, () => { + const root = bodyRef.current; + if (!root) return { saved: [], draft: [] }; + const saved: Range[] = []; + for (const a of annRef.current) { + const r = rangeFromOffsets(root, a.start, a.end); + if (r) saved.push(r); + } + const draftRanges: Range[] = []; + const d = draftRef.current; + if (d) { + const r = rangeFromOffsets(root, d.start, d.end); + if (r) draftRanges.push(r); + } + return { saved, draft: draftRanges }; + }); + republishHighlights(); + // The plan body re-renders asynchronously (highlight.js swaps a fenced block's text node + // for token spans AFTER a lazy import; the plan itself streams in). Those mutations detach + // our published ranges without touching any React dep, so observe the subtree and rebuild. + const root = bodyRef.current; + let raf = 0; + const obs = + root && typeof MutationObserver !== "undefined" + ? new MutationObserver(() => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + republishHighlights(); + }); + }) + : null; + if (obs && root) obs.observe(root, { childList: true, subtree: true, characterData: true }); + return () => { + providers.delete(key); + if (raf) cancelAnimationFrame(raf); + obs?.disconnect(); + republishHighlights(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session, toolUseId]); + + // Rebuild the visual highlights whenever the annotations, the in-progress draft, or the plan + // text change (the last covers the streamed-in plan settling to its final form). + useLayoutEffect(() => { + republishHighlights(); + }, [annotations, draft, plan]); + + // Dismiss the in-progress draft: drop the composer, reset its text, and clear the native + // selection so the passage stops looking highlighted (the draft highlight is keyed off + // `draft`; the OS selection tint would linger otherwise — the "Annuler left it highlighted" bug). + const clearDraft = () => { + setDraft(null); + setDraftText(""); + window.getSelection()?.removeAllRanges(); + }; + + // A resolved plan can't be annotated: tear down any open draft the moment the card stops being + // interactive (decided elsewhere, e.g. accept/reject, or answered from phone/web), so no stray + // composer/highlight floats over the settled card. + useEffect(() => { + if (!interactive && draft) clearDraft(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [interactive]); + + // Anchor the composer to the live selection rect, recomputed on scroll/resize and clamped to + // the viewport (flip above the selection if it would overflow the bottom). + useLayoutEffect(() => { + if (!draft) { + setPos(null); + return; + } + const place = () => { + const root = bodyRef.current; + if (!root) return; + const r = rangeFromOffsets(root, draft.start, draft.end); + const rect = r?.getBoundingClientRect(); + if (!rect) return; + const maxLeft = window.innerWidth - COMPOSER_W - VIEWPORT_MARGIN; + const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, maxLeft)); + let top = rect.bottom + 6; + if (top + COMPOSER_H > window.innerHeight) + top = Math.max(VIEWPORT_MARGIN, rect.top - COMPOSER_H - 6); + setPos({ left, top }); + }; + place(); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [draft]); + + const onMouseUp = () => { + if (!interactive) return; + const root = bodyRef.current; + const sel = window.getSelection(); + if (!root || !sel || sel.rangeCount === 0 || sel.isCollapsed) { + // A plain click (no selection) inside the plan dismisses an open composer. + if (draftRef.current) clearDraft(); + return; + } + const range = sel.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return; + const off = selectionOffsets(root, range); + if (!off) return; + setDraft({ ...off, quote: range.toString() }); + setDraftText(""); + // Replace the native selection with our own persistent draft highlight: it survives the + // composer stealing focus, and cancelling then fully clears the passage. + sel.removeAllRanges(); + }; + + const commitDraft = () => { + const comment = draftText.trim(); + if (!draft || comment === "") return; // a comment must carry text — no empty flags + const ann: PlanAnnotation = { + id: newAnnotationId(), + start: draft.start, + end: draft.end, + quote: draft.quote, + comment, + }; + addAnn(session, toolUseId, ann); + setDraft(null); + setDraftText(""); + window.getSelection()?.removeAllRanges(); + }; + + const accept = () => { + clearDraft(); + answer.mutate( + { + requestId: pending!.request_id, + decision: { behavior: "allow", updated_input: null }, + }, + { + // Approving a plan exits plan mode; the CLI would otherwise drop to "default". Switch to + // "auto" (auto-accept) instead. Done in onSuccess so the mode change is written to the + // control channel AFTER the approval — FIFO ordering means our "auto" wins over the CLI's + // implicit reset. setConvPermission both persists the choice and pushes set_permission_mode. + onSuccess: () => + useConversationsStore.getState().setConvPermission(session, "auto"), + }, + ); + }; + + const rejectAndRevise = () => { + clearDraft(); + answer.mutate({ + requestId: pending!.request_id, + decision: { behavior: "deny", message: buildRejectionMessage(annotations, note) }, + }); + setNote(session, toolUseId, ""); + }; + + // Every saved annotation carries a comment (empty ones can't be committed), so the count is + // simply how many there are; feedback exists when there's a comment or a general note. + const commentCount = annotations.length; + // The user has expressed feedback → sending it (a reject-and-revise) becomes the primary + // action, and plain "Accepter" steps back. With no feedback, accept is the primary action and + // the secondary is a plain refuse. + const hasFeedback = commentCount > 0 || note.trim() !== ""; + + return ( + <div className={"cv-plan" + (decision !== "none" ? ` is-${decision}` : "")}> + <div className="cv-plan-h"> + <Ico name="clipboard" className="sm cv-plan-ico" /> + <span className="cv-plan-t">Plan proposé</span> + <PlanBadge decision={decision} /> + </div> + + <div + ref={bodyRef} + className="cv-plan-body md-body" + onMouseUp={onMouseUp} + // Hint the user the text is selectable-to-comment only while it can drive a revision. + data-annotatable={interactive ? "1" : undefined} + > + <StreamMarkdown text={plan} /> + </div> + + {commentCount > 0 && ( + <div className="cv-plan-notes"> + <div className="cv-plan-notes-h"> + <Ico name="chat" className="sm" /> + {commentCount} commentaire{commentCount > 1 ? "s" : ""} + </div> + {annotations.map((a) => ( + <div key={a.id} className="cv-plan-note"> + <span className="cv-plan-note-quote" title={a.quote}> + {a.quote} + </span> + <span className="cv-plan-note-c"> + {a.comment.trim() ? a.comment : <em className="cv-plan-note-empty">sans note</em>} + </span> + {interactive && ( + <button + className="cv-plan-note-del" + title="Supprimer ce commentaire" + onClick={() => removeAnn(session, toolUseId, a.id)} + > + <Ico name="trash" className="sm" /> + </button> + )} + </div> + ))} + </div> + )} + + {interactive && ( + <div className="cv-plan-foot"> + <textarea + className="cv-plan-noteinput" + placeholder="Retour général sur le plan (optionnel)…" + value={note} + rows={2} + onChange={(e) => setNote(session, toolUseId, e.target.value)} + onKeyDown={(e) => { + // Keep typing keys local; only let thread-level shortcuts (⌘…) bubble. + if (!e.metaKey && !e.ctrlKey) e.stopPropagation(); + }} + /> + <div className="cv-plan-actions"> + {hasFeedback ? ( + <> + <button className="wf-btn ghost sm" onClick={accept}> + <Ico name="check" className="sm" /> + Accepter quand même + </button> + <button className="wf-btn prim sm" onClick={rejectAndRevise}> + <Ico name="reply" className="sm" /> + Envoyer les retours + {commentCount > 0 ? ` (${commentCount})` : ""} + </button> + </> + ) : ( + <> + <button className="wf-btn ghost sm" onClick={rejectAndRevise}> + <Ico name="x" className="sm" /> + Refuser + </button> + <button className="wf-btn prim sm" onClick={accept}> + <Ico name="check" className="sm" /> + Accepter le plan + </button> + </> + )} + </div> + <div className="cv-plan-hint"> + {hasFeedback + ? "« Envoyer les retours » renvoie tes commentaires à l'agent pour qu'il révise le plan." + : "Sélectionne un passage du plan pour le commenter, puis envoie tes retours à l'agent."} + </div> + </div> + )} + + {interactive && draft && pos && ( + <div + // Keyed on the passage so a re-selection remounts a fresh, empty, re-focused composer. + key={`${draft.start}-${draft.end}`} + className="cv-plan-composer" + style={{ left: pos.left, top: pos.top }} + onMouseUp={(e) => e.stopPropagation()} + > + <textarea + autoFocus + className="cv-plan-composer-input" + placeholder="Ton commentaire…" + rows={2} + value={draftText} + onChange={(e) => setDraftText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + clearDraft(); + } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + commitDraft(); + } else if (!e.metaKey && !e.ctrlKey) { + e.stopPropagation(); + } + }} + /> + <div className="cv-plan-composer-foot"> + <span className="cv-plan-composer-hint">⌘⏎ pour ajouter</span> + <span className="cv-plan-composer-btns"> + <button className="wf-btn ghost sm" onClick={clearDraft}> + Annuler + </button> + <button + className="wf-btn prim sm" + disabled={draftText.trim() === ""} + onClick={commitDraft} + > + Ajouter + </button> + </span> + </div> + </div> + )} + </div> + ); +} + +function mapResult(d: "approved" | "rejected" | "unknown"): Decision { + return d === "unknown" ? "none" : d; +} + +function PlanBadge({ decision }: { decision: Decision }) { + if (decision === "approved") + return ( + <span className="cv-plan-badge is-approved"> + <Ico name="check" className="sm" /> + Accepté + </span> + ); + if (decision === "rejected") + return ( + <span className="cv-plan-badge is-rejected"> + <Ico name="x" className="sm" /> + Refusé + </span> + ); + if (decision === "pending") + return ( + <span className="cv-plan-badge is-pending"> + <Ico name="clock" className="sm" /> + En attente + </span> + ); + return null; +} diff --git a/src/features/conversation/SubAgentTranscript.tsx b/src/features/conversation/SubAgentTranscript.tsx index 775f45a..d26c63e 100644 --- a/src/features/conversation/SubAgentTranscript.tsx +++ b/src/features/conversation/SubAgentTranscript.tsx @@ -39,10 +39,11 @@ function renderSegments(segments: Segment[], results: Map<string, JoinedResult>) return segments.map((seg) => { if (seg.kind === "text") return <StreamMarkdown key={seg.key} text={seg.text} />; if (seg.kind === "thinking") return <ThinkingBlock key={seg.key} text={seg.text} finalized />; - // A nested sub-agent OR workflow in a settled transcript: one step row (no live card to - // drill into). `workflow` segments (dev's Workflows feature) have a `.step`, not `.steps`, - // so they MUST be handled here — otherwise they fall through to the run branch and crash. - if (seg.kind === "agent" || seg.kind === "workflow") + // A nested sub-agent OR workflow OR proposed plan in a settled transcript: one step row + // (no live card to drill into). These `.step` segments (vs the run branch's `.steps`) MUST + // be handled here — otherwise they fall through to the run branch and crash. A sub-agent + // proposing a plan is a non-case in practice, but the union requires the branch. + if (seg.kind === "agent" || seg.kind === "workflow" || seg.kind === "plan") return <StaticToolStep key={seg.key} step={seg.step} result={results.get(seg.step.id)} />; // In-band markers only exist in the LIVE thread (interleaveMarkers); a disk transcript // has none, but the union requires the branch — render nothing. diff --git a/src/features/conversation/ToolSection.tsx b/src/features/conversation/ToolSection.tsx index fa25017..d536b2d 100644 --- a/src/features/conversation/ToolSection.tsx +++ b/src/features/conversation/ToolSection.tsx @@ -16,6 +16,7 @@ import { Ico } from "../../ui/kit"; import { DiffView } from "./DiffView"; import { MentionPathChip } from "./FileMention"; import { QuestionnaireSummary } from "./QuestionnaireAsk"; +import { StreamMarkdown } from "./StreamMarkdown"; import { ToolResultBody } from "./ToolResultBody"; import { toolMeta } from "./toolMeta"; import { WebToolDetail } from "./WebSources"; @@ -49,10 +50,17 @@ export function resultContentText(content: JsonValue): string | null { return null; } -/** Does this tool have an expandable detail body (diff / output / questionnaire)? */ +/** Does this tool have an expandable detail body (diff / output / questionnaire / plan)? */ function hasDetailFor(name: string, input: JsonValue, result: StepResult | undefined): boolean { const kind = toolMeta(name, input).kind; - return kind === "edit" || kind === "write" || kind === "bash" || name === "AskUserQuestion" || !!result; + return ( + kind === "edit" || + kind === "write" || + kind === "bash" || + name === "AskUserQuestion" || + name === "ExitPlanMode" || + !!result + ); } /** The expanded detail of a step: a diff (Edit/Write), the command + output (Bash), @@ -70,6 +78,10 @@ export function ToolDetail({ if (name === "AskUserQuestion") return <QuestionnaireSummary input={input} result={result?.content} />; + // The proposed plan (rare in a settled/sub-agent transcript, where the rich <PlanCard> is not + // used): render the plan markdown so an expanded row isn't empty. + if (name === "ExitPlanMode") return <StreamMarkdown text={field(input, "plan") ?? ""} />; + if (meta.kind === "edit") { if (name === "MultiEdit") return ( diff --git a/src/features/conversation/planStatus.test.ts b/src/features/conversation/planStatus.test.ts new file mode 100644 index 0000000..5ec1e21 --- /dev/null +++ b/src/features/conversation/planStatus.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { PlanAnnotation } from "../../store/planAnnotations"; +import { buildRejectionMessage, planResultDecision } from "./planStatus"; + +const ann = (quote: string, comment: string): PlanAnnotation => ({ + id: quote, + start: 0, + end: quote.length, + quote, + comment, +}); + +describe("planResultDecision", () => { + it("reads the CLI approval phrasing as approved", () => { + const content = + "User has approved your plan. You can now start coding. Start with updating your todo list"; + expect(planResultDecision(content, false)).toBe("approved"); + }); + + it("treats an error result (denied permission carrying our message) as rejected", () => { + expect(planResultDecision("L'utilisateur a refusé le plan.", true)).toBe("rejected"); + }); + + it("reads explicit rejection phrasing as rejected even without the error flag", () => { + expect(planResultDecision("The user rejected the plan, keep planning.", false)).toBe("rejected"); + }); + + it("flattens a content-block array before matching", () => { + const content = [{ type: "text", text: "User has approved your plan." }] as never; + expect(planResultDecision(content, false)).toBe("approved"); + }); + + it("stays unknown for an unrecognized, non-error result (never mislabels)", () => { + expect(planResultDecision("some unrelated output", false)).toBe("unknown"); + }); +}); + +describe("buildRejectionMessage", () => { + it("returns a generic refusal when there is nothing to say", () => { + const msg = buildRejectionMessage([], " "); + expect(msg).toMatch(/refusé le plan/i); + expect(msg).not.toMatch(/Retour général/); + expect(msg).not.toMatch(/Commentaires/); + }); + + it("bundles the general note", () => { + const msg = buildRejectionMessage([], "trop ambitieux"); + expect(msg).toMatch(/Retour général : trop ambitieux/); + }); + + it("bundles per-passage comments as quote → note, skipping empty comments", () => { + const msg = buildRejectionMessage( + [ann("Refactorer l'auth", "trop risqué"), ann("Étape 3", " ")], + "", + ); + expect(msg).toMatch(/> Refactorer l'auth/); + expect(msg).toMatch(/→ trop risqué/); + // The empty-comment annotation is omitted. + expect(msg).not.toMatch(/Étape 3/); + }); + + it("collapses whitespace inside a quoted excerpt", () => { + const msg = buildRejectionMessage([ann("ligne un\n ligne deux", "revois ça")], ""); + expect(msg).toMatch(/> ligne un ligne deux/); + }); +}); diff --git a/src/features/conversation/planStatus.ts b/src/features/conversation/planStatus.ts new file mode 100644 index 0000000..8d1c040 --- /dev/null +++ b/src/features/conversation/planStatus.ts @@ -0,0 +1,77 @@ +// Pure helpers for the plan card (ExitPlanMode): derive the plan's decision from its +// tool_result, and format the user's annotations into a feedback message sent back to the +// agent on "reject & revise". Kept framework-free so both are unit-testable. + +import type { JsonValue } from "../../ipc/client"; +import type { PlanAnnotation } from "../../store/planAnnotations"; + +/** The plan's outcome as read from its settled tool_result (the live "pending" state is + * decided by the presence of an open permission, not here). `unknown` = a result we can't + * classify (never mislabel it approved/rejected). */ +export type PlanResultDecision = "approved" | "rejected" | "unknown"; + +/** Flatten a tool_result content (string or content-block array) to plain text. */ +function flatten(content: JsonValue): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const b of content) { + if (b && typeof b === "object" && !Array.isArray(b)) { + const t = (b as Record<string, JsonValue>).text; + if (typeof t === "string") parts.push(t); + } + } + return parts.join("\n"); + } + return ""; +} + +/** + * Classify a settled ExitPlanMode result. The CLI returns, on approval: + * "User has approved your plan. You can now start coding. …" + * A denied permission comes back as an ERROR result carrying our deny message (or the CLI's + * own rejection phrasing). Anything else stays `unknown` so the card shows a neutral state + * rather than a wrong badge. + */ +export function planResultDecision(content: JsonValue, isError: boolean): PlanResultDecision { + const text = flatten(content).toLowerCase(); + if (!isError && /approved your plan|has approved the plan|approved the plan/.test(text)) + return "approved"; + if (isError) return "rejected"; + if (/reject|declin|keep planning|did not approve|didn.?t approve|does ?n.?t want to proceed/.test(text)) + return "rejected"; + return "unknown"; +} + +/** + * Build the deny message sent to the agent when the user rejects a plan with feedback. Each + * annotation becomes a quoted excerpt + the user's note; an optional general note is appended. + * The agent reads this as the tool_result of the refused ExitPlanMode and revises accordingly. + * Returns a generic refusal when there is nothing to say, so "reject" always sends something. + */ +export function buildRejectionMessage( + annotations: ReadonlyArray<PlanAnnotation>, + generalNote: string, +): string { + const note = generalNote.trim(); + const withComments = annotations.filter((a) => a.comment.trim() !== ""); + if (withComments.length === 0 && note === "") + return "L'utilisateur a refusé le plan. Retravaille ta proposition."; + + const lines: string[] = [ + "L'utilisateur a refusé le plan et laissé des retours à intégrer avant de reproposer un plan :", + "", + ]; + if (note !== "") { + lines.push(`Retour général : ${note}`, ""); + } + if (withComments.length > 0) { + lines.push("Commentaires sur des passages précis du plan :", ""); + for (const a of withComments) { + const quote = a.quote.trim().replace(/\s+/g, " "); + lines.push(`> ${quote}`, ` → ${a.comment.trim()}`, ""); + } + } + lines.push("Reprends le plan en tenant compte de ces retours."); + return lines.join("\n").trim(); +} diff --git a/src/features/conversation/toolGroup.test.ts b/src/features/conversation/toolGroup.test.ts index 9fa7aef..117385d 100644 --- a/src/features/conversation/toolGroup.test.ts +++ b/src/features/conversation/toolGroup.test.ts @@ -159,6 +159,53 @@ describe("groupBlocks — Workflow", () => { }); }); +describe("groupBlocks — ExitPlanMode (proposed plan)", () => { + it("emits a dedicated `plan` segment that breaks the surrounding run", () => { + const segs = groupBlocks([ + tool("r", "Read", { file_path: "a.ts" }), + tool("p", "ExitPlanMode", { plan: "# Plan\n- do a thing" }), + tool("g", "Grep", { pattern: "x" }), + ]); + expect(segs.map((s) => s.kind)).toEqual(["run", "plan", "run"]); + const pl = segs[1]; + if (pl.kind === "plan") expect(pl.step.id).toBe("p"); + }); + + it("is NOT hidden inline (must always show), unlike a background tool", () => { + expect(isHiddenInline("ExitPlanMode", { plan: "x" })).toBe(false); + }); + + it("does not count the plan as a work step (it's a decision, not work)", () => { + const segs = groupBlocks([tool("a", "Read"), tool("p", "ExitPlanMode", { plan: "x" })]); + expect(countWorkSteps(segs)).toBe(1); + expect(workStepIds(segs)).toEqual(["a"]); + }); + + it("peels a TRAILING plan (with any closing prose) into `final` so it stays in clear", () => { + // A pending plan is the last block — the agent pauses right after ExitPlanMode. It must NOT + // fold into the work block: the user has to see it to accept/reject. + const { work, final } = splitFinalMessage( + groupBlocks([ + tool("a", "Read"), + tool("b", "Edit"), + tool("p", "ExitPlanMode", { plan: "# Plan" }), + ]), + ); + expect(work.map((s) => s.kind)).toEqual(["run"]); + expect(final.map((s) => s.kind)).toEqual(["plan"]); + }); + + it("survives a flattenWork → atomsToSegments round-trip (plan stays its own atom)", () => { + const work = groupBlocks([tool("a", "Read"), tool("p", "ExitPlanMode", { plan: "x" })]); + const atoms = flattenWork(work); + expect(atoms.map((a) => a.kind)).toEqual(["step", "plan"]); + const back = atomsToSegments(atoms, "vis"); + expect(back.map((s) => s.kind)).toEqual(["run", "plan"]); + const pl = back[1]; + if (pl.kind === "plan") expect(pl.step.id).toBe("p"); + }); +}); + describe("groupBlocks — in-band markers (mid-turn separator)", () => { it("emits a `marker` segment that breaks the run without being a step", () => { const segs = groupBlocks([tool("a", "Read"), mkMarker("cc"), tool("b", "Read")]); diff --git a/src/features/conversation/toolGroup.ts b/src/features/conversation/toolGroup.ts index 62d8127..71b379c 100644 --- a/src/features/conversation/toolGroup.ts +++ b/src/features/conversation/toolGroup.ts @@ -83,6 +83,11 @@ export type Segment = // A `Workflow` renders as its OWN persistent inline card (live overview → post-run report), // like a sub-agent: dedicated segment, breaks the run, never hidden. | { kind: "workflow"; key: string; step: ToolStep } + // `ExitPlanMode` — the proposed plan — renders as its OWN prominent inline card (markdown + // plan + accept/reject decision + annotations). Like a sub-agent it gets a dedicated + // segment that breaks the surrounding run; it is a DECISION artifact, never intermediate + // work, so it is also peeled out of the clean-output work fold (see splitFinalMessage). + | { kind: "plan"; key: string; step: ToolStep } // An in-band marker (control-change bar / message injected mid-work) shown inline in the // flow. NOT work: it doesn't count as a step and never breaks the clean-output work fold — // it just renders at its chronological place (see coalesceCleanRounds / interleaveMarkers). @@ -159,6 +164,12 @@ export function groupBlocks( out.push({ kind: "workflow", key: `w-${i}`, step: { id: b.id, name: b.name, input: b.input } }); return; } + // ExitPlanMode is the proposed plan — its own prominent inline card, breaks the run. + if (b.name === "ExitPlanMode") { + run = null; + out.push({ kind: "plan", key: `p-${i}`, step: { id: b.id, name: b.name, input: b.input } }); + return; + } if (!run) { run = []; out.push({ kind: "run", key: `run-${i}`, steps: run }); @@ -205,8 +216,20 @@ export function splitFinalMessage(segments: Segment[]): { // marker stays in clear next to the message instead of emptying `final` and defeating the fold // (which would render the whole settled round unfolded). A marker separated from the final text // by a run still stops the scan at that run — only intermediate work folds. + // + // A trailing `plan` (ExitPlanMode) is peeled too: the proposed plan is a decision artifact, not + // intermediate work — it must stay in clear even under clean output, especially while it awaits + // approval (the agent pauses right after it). A plan buried mid-response (e.g. a resumed history + // where the approved plan is followed by more work in the same group) stays in the fold — the + // scan stops at the trailing run, which is acceptable for that settled-history case. let i = segments.length; - while (i > 0 && (segments[i - 1].kind === "text" || segments[i - 1].kind === "marker")) i--; + while ( + i > 0 && + (segments[i - 1].kind === "text" || + segments[i - 1].kind === "marker" || + segments[i - 1].kind === "plan") + ) + i--; return { work: segments.slice(0, i), final: segments.slice(i) }; } @@ -220,6 +243,7 @@ export type WorkAtom = | { kind: "step"; key: string; step: ToolStep } | { kind: "agent"; key: string; step: ToolStep } | { kind: "workflow"; key: string; step: ToolStep } + | { kind: "plan"; key: string; step: ToolStep } | { kind: "thinking"; key: string; text: string } | { kind: "text"; key: string; text: string } // An in-band marker: a non-step atom (like text/thinking) — it folds with the surrounding @@ -236,6 +260,8 @@ export function flattenWork(segs: Segment[]): WorkAtom[] { out.push({ kind: "agent", key: seg.key, step: seg.step }); } else if (seg.kind === "workflow") { out.push({ kind: "workflow", key: seg.key, step: seg.step }); + } else if (seg.kind === "plan") { + out.push({ kind: "plan", key: seg.key, step: seg.step }); } else if (seg.kind === "thinking") { out.push({ kind: "thinking", key: seg.key, text: seg.text }); } else if (seg.kind === "marker") { @@ -266,6 +292,7 @@ export function atomsToSegments(atoms: WorkAtom[], keyPrefix: string): Segment[] run = null; if (a.kind === "agent") out.push({ kind: "agent", key: a.key, step: a.step }); else if (a.kind === "workflow") out.push({ kind: "workflow", key: a.key, step: a.step }); + else if (a.kind === "plan") out.push({ kind: "plan", key: a.key, step: a.step }); else if (a.kind === "thinking") out.push({ kind: "thinking", key: a.key, text: a.text }); else if (a.kind === "marker") out.push({ kind: "marker", key: a.key, markerKind: a.markerKind, id: a.id }); diff --git a/src/features/conversation/toolMeta.ts b/src/features/conversation/toolMeta.ts index 8bde95b..4968c54 100644 --- a/src/features/conversation/toolMeta.ts +++ b/src/features/conversation/toolMeta.ts @@ -1,5 +1,6 @@ import { Bot, + ClipboardList, FilePen, FilePlus, FileText, @@ -81,6 +82,15 @@ export function toolMeta(name: string, input: JsonValue): ToolMeta { return { icon: Bot, primaryArg: str(obj.description), suppressed: false, kind: "plain" }; case "Skill": return { icon: Sparkles, primaryArg: str(obj.skill), suppressed: false, kind: "plain" }; + case "ExitPlanMode": + // The proposed plan. NOT suppressed: it must stay visible in the thread, where it is + // pulled out as a dedicated <PlanCard> (see groupBlocks' `plan` segment) rather than a + // generic tool card. + return { icon: ClipboardList, primaryArg: null, suppressed: false, kind: "plain" }; + case "EnterPlanMode": + // Entering plan mode carries no useful inline content — suppress the bare tool card so it + // doesn't clutter the thread (the mode chip already conveys the state). + return { icon: ClipboardList, primaryArg: null, suppressed: true, kind: "plain" }; default: return { // MCP tools (`mcp__server__tool`) get a plug; everything else a generic wrench. diff --git a/src/store/conversationsStore.ts b/src/store/conversationsStore.ts index ab24823..11618fb 100644 --- a/src/store/conversationsStore.ts +++ b/src/store/conversationsStore.ts @@ -32,6 +32,13 @@ import { getCachedWindow, clearCachedWindow, clearAllCachedWindows } from "./con import { clearTodoBarOpen, clearAllTodoBarOpen } from "./todoBarUi"; import { clearComposerDraft, clearAllComposerDrafts } from "./composerDrafts"; import { clearWorkFold, clearAllWorkFold } from "./workFold"; +import { + clearPlanAnnotations, + clearAllPlanAnnotations, + snapshotPlanAnnotations, + restorePlanAnnotations, + type PlanConvSnapshot, +} from "./planAnnotations"; import { clearSidebarFold, clearAllSidebarFold } from "./sidebarFold"; import { disposeTerminal, disposeAllTerminals } from "../features/terminal/cleanup"; import { useGitViewStore } from "../features/git/gitViewStore"; @@ -183,6 +190,10 @@ const lastAppliedSeq = new Map<string, number>(); // an accidental delete is undoable within the session, not across restarts. Cleared // on wipe. Drained by ⌘Z via [undoRemoveConversation]. const removedConversations: Conversation[] = []; +// Parallel to the undo stack: a deleted conversation's plan annotations + notes (user-authored +// content), keyed by id so ⌘Z can re-seed them. Without this, clearPlanAnnotations on delete +// would silently lose the comments even though the row itself is restorable. +const removedPlanAnnotations = new Map<string, PlanConvSnapshot | null>(); // ---- Persistence adapter ---------------------------------------------------- // The ONE place that knows the SQL-facing DTO shape (snake_case `*Record`). @@ -394,6 +405,7 @@ export const useConversationsStore = create<ConversationsState>()((set, get) => clearTodoBarOpen(c.id); clearComposerDraft(c.id); clearWorkFold(c.id); + clearPlanAnnotations(c.id); useGitViewStore.getState().clear(c.id); useRemoteControlStore.getState().clear(c.id); useLastMessageSummaryStore.getState().clear(c.id); @@ -434,7 +446,12 @@ export const useConversationsStore = create<ConversationsState>()((set, get) => // Push the pre-teardown snapshot onto the undo stack BEFORE we mutate anything, // so ⌘Z (undoRemoveConversation) can restore the row exactly as it was. The delete // is friction-free (no confirm dialog), so this is the safety net. - if (conv) removedConversations.push(conv); + if (conv) { + removedConversations.push(conv); + // Capture the plan annotations/notes BEFORE clearing them below, so undo can restore + // this user content (the row is restorable but the comments would otherwise be gone). + removedPlanAnnotations.set(id, snapshotPlanAnnotations(id)); + } set((s) => { const rest = s.conversations.filter((c) => c.id !== id); return { @@ -456,6 +473,7 @@ export const useConversationsStore = create<ConversationsState>()((set, get) => clearTodoBarOpen(id); clearComposerDraft(id); clearWorkFold(id); + clearPlanAnnotations(id); autoTitlePending.delete(id); titleContext.delete(id); titleGenCount.delete(id); @@ -492,6 +510,9 @@ export const useConversationsStore = create<ConversationsState>()((set, get) => // re-spawns it via --resume). addConversation re-selects + re-persists the row. historyLoaded.delete(snapshot.id); get().addConversation({ ...snapshot, handle: null }); + // Re-seed the plan annotations/notes captured at delete time (user content). + restorePlanAnnotations(snapshot.id, removedPlanAnnotations.get(snapshot.id) ?? null); + removedPlanAnnotations.delete(snapshot.id); return true; }, @@ -1180,12 +1201,14 @@ export async function wipeAllData(): Promise<void> { clearAllTodoBarOpen(); clearAllComposerDrafts(); clearAllWorkFold(); + clearAllPlanAnnotations(); clearAllSidebarFold(); autoTitlePending.clear(); titleContext.clear(); titleGenCount.clear(); lastAppliedSeq.clear(); removedConversations.length = 0; // nothing to "undo" back into a wiped slate + removedPlanAnnotations.clear(); clearMentionCache(); useConversationsStore.setState({ repos: [], conversations: [], activeId: null }); useConversationStore.setState({ sessions: {} }); diff --git a/src/store/planAnnotations.test.ts b/src/store/planAnnotations.test.ts new file mode 100644 index 0000000..75bc275 --- /dev/null +++ b/src/store/planAnnotations.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { PlanAnnotation } from "./planAnnotations"; + +const ANN_KEY = "tosse:planannotations"; +const NOTE_KEY = "tosse:plannotes"; + +// The store reads localStorage at module-eval time, so each test imports a FRESH module. +async function freshStore() { + vi.resetModules(); + return await import("./planAnnotations"); +} + +const ann = (id: string, comment: string): PlanAnnotation => ({ + id, + start: 0, + end: 5, + quote: "hello", + comment, +}); + +describe("planAnnotations store", () => { + beforeEach(() => localStorage.clear()); + + it("adds and removes an annotation, persisting to localStorage", async () => { + const { usePlanAnnotationsStore } = await freshStore(); + usePlanAnnotationsStore.getState().add("conv", "tool", ann("a1", "risqué")); + expect(usePlanAnnotationsStore.getState().byConv.conv.tool).toHaveLength(1); + expect(JSON.parse(localStorage.getItem(ANN_KEY)!).conv.tool[0].comment).toBe("risqué"); + + usePlanAnnotationsStore.getState().remove("conv", "tool", "a1"); + // Emptying a tool prunes the conversation entirely. + expect(usePlanAnnotationsStore.getState().byConv.conv).toBeUndefined(); + }); + + it("stores and clears the general note under a separate key", async () => { + const { usePlanAnnotationsStore } = await freshStore(); + usePlanAnnotationsStore.getState().setNote("conv", "tool", "trop ambitieux"); + expect(usePlanAnnotationsStore.getState().notes.conv.tool).toBe("trop ambitieux"); + expect(JSON.parse(localStorage.getItem(NOTE_KEY)!).conv.tool).toBe("trop ambitieux"); + + usePlanAnnotationsStore.getState().setNote("conv", "tool", ""); + expect(usePlanAnnotationsStore.getState().notes.conv).toBeUndefined(); + }); + + it("survives a remount (re-read from localStorage) — the switch-survival case", async () => { + const first = await freshStore(); + first.usePlanAnnotationsStore.getState().add("conv", "tool", ann("a1", "note")); + first.usePlanAnnotationsStore.getState().setNote("conv", "tool", "général"); + const second = await freshStore(); + expect(second.usePlanAnnotationsStore.getState().byConv.conv.tool[0].comment).toBe("note"); + expect(second.usePlanAnnotationsStore.getState().notes.conv.tool).toBe("général"); + }); + + it("clearConversation forgets both annotations and notes for one conversation only", async () => { + const { usePlanAnnotationsStore, clearPlanAnnotations } = await freshStore(); + usePlanAnnotationsStore.getState().add("a", "t", ann("x", "c")); + usePlanAnnotationsStore.getState().setNote("a", "t", "n"); + usePlanAnnotationsStore.getState().add("b", "t", ann("y", "c")); + clearPlanAnnotations("a"); + expect(usePlanAnnotationsStore.getState().byConv.a).toBeUndefined(); + expect(usePlanAnnotationsStore.getState().notes.a).toBeUndefined(); + expect(usePlanAnnotationsStore.getState().byConv.b).toBeDefined(); + }); + + it("snapshot → clear → restore round-trips a conversation's plan state (the undo case)", async () => { + const { usePlanAnnotationsStore, snapshotPlanAnnotations, restorePlanAnnotations, clearPlanAnnotations } = + await freshStore(); + usePlanAnnotationsStore.getState().add("conv", "tool", ann("a1", "revois ça")); + usePlanAnnotationsStore.getState().setNote("conv", "tool", "note générale"); + + const snap = snapshotPlanAnnotations("conv"); + expect(snap).not.toBeNull(); + + clearPlanAnnotations("conv"); // the delete + expect(usePlanAnnotationsStore.getState().byConv.conv).toBeUndefined(); + expect(usePlanAnnotationsStore.getState().notes.conv).toBeUndefined(); + + restorePlanAnnotations("conv", snap); // the ⌘Z undo + expect(usePlanAnnotationsStore.getState().byConv.conv.tool[0].comment).toBe("revois ça"); + expect(usePlanAnnotationsStore.getState().notes.conv.tool).toBe("note générale"); + }); + + it("the snapshot is decoupled from later store mutations", async () => { + const { usePlanAnnotationsStore, snapshotPlanAnnotations, restorePlanAnnotations, clearPlanAnnotations } = + await freshStore(); + usePlanAnnotationsStore.getState().add("conv", "tool", ann("a1", "original")); + const snap = snapshotPlanAnnotations("conv"); + // Mutate the live store AFTER snapshotting. + usePlanAnnotationsStore.getState().add("conv", "tool", ann("a2", "later")); + clearPlanAnnotations("conv"); + restorePlanAnnotations("conv", snap); + // Only the snapshotted annotation comes back, not the post-snapshot one. + const list = usePlanAnnotationsStore.getState().byConv.conv.tool; + expect(list).toHaveLength(1); + expect(list[0].comment).toBe("original"); + }); + + it("snapshot of an empty conversation is null, and restoring null is a no-op", async () => { + const { usePlanAnnotationsStore, snapshotPlanAnnotations, restorePlanAnnotations } = await freshStore(); + expect(snapshotPlanAnnotations("nope")).toBeNull(); + restorePlanAnnotations("nope", null); + expect(usePlanAnnotationsStore.getState().byConv.nope).toBeUndefined(); + }); +}); diff --git a/src/store/planAnnotations.ts b/src/store/planAnnotations.ts new file mode 100644 index 0000000..0d1c1f5 --- /dev/null +++ b/src/store/planAnnotations.ts @@ -0,0 +1,196 @@ +// User annotations on a proposed plan (ExitPlanMode) — a highlighted span of the rendered +// plan text plus a comment, PLUS a per-plan general note, keyed by conversation id → tool_use +// id. Persisted to localStorage with the same lightweight pattern as workFold.ts / display.ts +// (pure UI state, kept out of the SQLite core — no schema migration). +// +// Why a store and not local component state: the conversation pane is remounted per +// conversation (`key={conv.id}`), so an annotation or note typed in a <PlanCard> would reset +// on a switch-away-and-back. A global store survives that remount; localStorage additionally +// carries it across app restarts, so a plan you reviewed keeps its notes. +// +// Offsets are character indices into the plan card's RENDERED text (`.md-body` textContent), +// which is stable because the plan markdown is immutable — so a stored [start,end) re-derives +// the same DOM range on every render (see PlanCard's highlight rebuild). `quote` is the +// captured text: it feeds the "reject & revise" feedback message and is a readable fallback. +// +// Deleting a conversation is UNDOABLE (⌘Z), so removal SNAPSHOTS a conversation's annotations +// + notes and the undo re-seeds them — this is deliberate user content, unlike the transient +// UI caches cleared the same way (see conversationsStore removeConversation/undo). +import { create } from "zustand"; + +const ANN_KEY = "tosse:planannotations"; +const NOTE_KEY = "tosse:plannotes"; + +export interface PlanAnnotation { + id: string; + /** Character offset (inclusive) into the rendered plan text. */ + start: number; + /** Character offset (exclusive) into the rendered plan text. */ + end: number; + /** The highlighted text — for the feedback message + a readable fallback. */ + quote: string; + /** The user's note on that span. */ + comment: string; +} + +// convId → toolUseId → annotations. Absent = none, so we only store what the user created. +type AnnMap = Record<string, Record<string, PlanAnnotation[]>>; +// convId → toolUseId → general note text. +type NoteMap = Record<string, Record<string, string>>; + +/** A conversation's plan state, captured for undo. */ +export interface PlanConvSnapshot { + ann: Record<string, PlanAnnotation[]>; + notes: Record<string, string>; +} + +function load<T>(key: string): T { + try { + const raw = localStorage.getItem(key); + if (!raw) return {} as T; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" ? (parsed as T) : ({} as T); + } catch { + return {} as T; + } +} + +function save(key: string, map: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(map)); + } catch { + /* quota / disabled storage — best-effort, ignore */ + } +} + +/** A unique enough id for a local annotation (no collisions within one plan in practice). */ +export function newAnnotationId(): string { + const c = globalThis.crypto as Crypto | undefined; + if (c && typeof c.randomUUID === "function") return c.randomUUID(); + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +const EMPTY: PlanAnnotation[] = []; + +interface PlanAnnotationsState { + byConv: AnnMap; + notes: NoteMap; + add: (conv: string, toolUseId: string, ann: PlanAnnotation) => void; + remove: (conv: string, toolUseId: string, id: string) => void; + setNote: (conv: string, toolUseId: string, note: string) => void; + clearConversation: (conv: string) => void; + clearAll: () => void; +} + +function withList( + map: AnnMap, + conv: string, + toolUseId: string, + next: PlanAnnotation[], +): AnnMap { + const convMap = { ...(map[conv] ?? {}) }; + if (next.length === 0) delete convMap[toolUseId]; + else convMap[toolUseId] = next; + const out = { ...map }; + if (Object.keys(convMap).length === 0) delete out[conv]; + else out[conv] = convMap; + return out; +} + +function withNote(map: NoteMap, conv: string, toolUseId: string, note: string): NoteMap { + const convMap = { ...(map[conv] ?? {}) }; + if (note === "") delete convMap[toolUseId]; + else convMap[toolUseId] = note; + const out = { ...map }; + if (Object.keys(convMap).length === 0) delete out[conv]; + else out[conv] = convMap; + return out; +} + +export const usePlanAnnotationsStore = create<PlanAnnotationsState>((set) => ({ + byConv: load<AnnMap>(ANN_KEY), + notes: load<NoteMap>(NOTE_KEY), + add: (conv, toolUseId, ann) => + set((s) => { + const list = s.byConv[conv]?.[toolUseId] ?? EMPTY; + const next = withList(s.byConv, conv, toolUseId, [...list, ann]); + save(ANN_KEY, next); + return { byConv: next }; + }), + remove: (conv, toolUseId, id) => + set((s) => { + const list = s.byConv[conv]?.[toolUseId]; + if (!list) return s; + const next = withList(s.byConv, conv, toolUseId, list.filter((a) => a.id !== id)); + save(ANN_KEY, next); + return { byConv: next }; + }), + setNote: (conv, toolUseId, note) => + set((s) => { + const next = withNote(s.notes, conv, toolUseId, note); + save(NOTE_KEY, next); + return { notes: next }; + }), + clearConversation: (conv) => + set((s) => { + const hasAnn = conv in s.byConv; + const hasNote = conv in s.notes; + if (!hasAnn && !hasNote) return s; + const byConv = { ...s.byConv }; + const notes = { ...s.notes }; + delete byConv[conv]; + delete notes[conv]; + save(ANN_KEY, byConv); + save(NOTE_KEY, notes); + return { byConv, notes }; + }), + clearAll: () => + set(() => { + save(ANN_KEY, {}); + save(NOTE_KEY, {}); + return { byConv: {}, notes: {} }; + }), +})); + +/** Subscribe to one plan's annotations (stable EMPTY ref when none, so no needless renders). */ +export const usePlanAnnotations = (conv: string, toolUseId: string): PlanAnnotation[] => + usePlanAnnotationsStore((s) => s.byConv[conv]?.[toolUseId] ?? EMPTY); + +/** Subscribe to one plan's general note (empty string when none). */ +export const usePlanNote = (conv: string, toolUseId: string): string => + usePlanAnnotationsStore((s) => s.notes[conv]?.[toolUseId] ?? ""); + +/** Imperative clears for non-React callers (conversationsStore removal / wipe). */ +export function clearPlanAnnotations(conv: string): void { + usePlanAnnotationsStore.getState().clearConversation(conv); +} +export function clearAllPlanAnnotations(): void { + usePlanAnnotationsStore.getState().clearAll(); +} + +/** Snapshot a conversation's plan state before an UNDOABLE delete, or null if it has none. */ +export function snapshotPlanAnnotations(conv: string): PlanConvSnapshot | null { + const s = usePlanAnnotationsStore.getState(); + const ann = s.byConv[conv]; + const notes = s.notes[conv]; + if (!ann && !notes) return null; + // Deep-ish copy so a later mutation of the live store can't corrupt the snapshot. + return { + ann: ann ? structuredClone(ann) : {}, + notes: notes ? { ...notes } : {}, + }; +} + +/** Re-seed a conversation's plan state when its delete is undone (⌘Z). */ +export function restorePlanAnnotations(conv: string, snap: PlanConvSnapshot | null): void { + if (!snap) return; + usePlanAnnotationsStore.setState((s) => { + const byConv = { ...s.byConv }; + const notes = { ...s.notes }; + if (Object.keys(snap.ann).length > 0) byConv[conv] = snap.ann; + if (Object.keys(snap.notes).length > 0) notes[conv] = snap.notes; + save(ANN_KEY, byConv); + save(NOTE_KEY, notes); + return { byConv, notes }; + }); +} diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index a3c3b2b..377a416 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -350,6 +350,64 @@ .cv-meta-grid span:first-child { font-size:10px; text-transform:uppercase; letter-spacing:.5px; color:var(--wf-tx-xlo); } .cv-meta-grid span:last-child { font-size:12px; } +/* ---- Proposed-plan card (ExitPlanMode) ------------------------------------------------ + A prominent decision block (NOT a foldable tool step): the plan markdown, an accept/reject + footer while it awaits approval, and the user's inline comments. Accented with the same + blue as the "Plan mode" chip (--wf-perm-plan). */ +.cv-plan { border:1px solid color-mix(in srgb, var(--wf-perm-plan) 34%, var(--wf-line-2)); background:var(--wf-panel); border-radius:13px; overflow:hidden; position:relative; } +.cv-plan.is-pending { border-color:color-mix(in srgb, var(--wf-perm-plan) 52%, transparent); box-shadow:0 0 0 3px color-mix(in srgb, var(--wf-perm-plan) 12%, transparent); } +.cv-plan.is-rejected { border-color:color-mix(in srgb, var(--wf-err) 32%, var(--wf-line-2)); } + +.cv-plan-h { display:flex; align-items:center; gap:8px; padding:9px 13px; background:color-mix(in srgb, var(--wf-perm-plan) 12%, transparent); border-bottom:1px solid color-mix(in srgb, var(--wf-perm-plan) 20%, var(--wf-line)); } +.cv-plan-ico { color:var(--wf-perm-plan); flex:none; } +.cv-plan-t { font-size:12.5px; font-weight:600; color:var(--wf-tx-hi); flex:1; min-width:0; letter-spacing:.2px; } + +.cv-plan-badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:500; padding:3px 9px; border-radius:20px; border:1px solid transparent; } +.cv-plan-badge .wf-ico { width:12px; height:12px; } +.cv-plan-badge.is-approved { color:var(--wf-run); background:color-mix(in srgb, var(--wf-run) 14%, transparent); border-color:color-mix(in srgb, var(--wf-run) 32%, transparent); } +.cv-plan-badge.is-rejected { color:var(--wf-err); background:color-mix(in srgb, var(--wf-err) 13%, transparent); border-color:color-mix(in srgb, var(--wf-err) 30%, transparent); } +.cv-plan-badge.is-pending { color:var(--wf-perm-plan); background:color-mix(in srgb, var(--wf-perm-plan) 15%, transparent); border-color:color-mix(in srgb, var(--wf-perm-plan) 34%, transparent); } + +.cv-plan-body { padding:6px 15px 10px; } +/* Only the plan text is selectable-to-comment; the affordance is the text cursor + a subtle + selection tint that reads as "you can mark this". */ +.cv-plan-body[data-annotatable] { cursor:text; } +.cv-plan-body[data-annotatable]::selection, +.cv-plan-body[data-annotatable] ::selection { background:color-mix(in srgb, var(--wf-perm-plan) 40%, transparent); } + +/* Annotation highlights (CSS Custom Highlight API). Limited to the properties the spec allows + on ::highlight (background/underline). Two layers: a SAVED comment (solid) vs the passage + being commented right now (DRAFT, dashed + fainter) so they read differently. */ +::highlight(tosse-plan-annotation) { background-color:color-mix(in srgb, var(--wf-perm-plan) 30%, transparent); text-decoration:underline; text-decoration-color:var(--wf-perm-plan); text-decoration-thickness:2px; } +::highlight(tosse-plan-draft) { background-color:color-mix(in srgb, var(--wf-perm-plan) 18%, transparent); text-decoration:underline; text-decoration-style:dashed; text-decoration-color:color-mix(in srgb, var(--wf-perm-plan) 75%, transparent); } + +.cv-plan-notes { display:flex; flex-direction:column; gap:5px; padding:9px 13px; border-top:1px solid var(--wf-line); background:var(--wf-bg-2); } +.cv-plan-notes-h { display:flex; align-items:center; gap:6px; font-size:11px; text-transform:uppercase; letter-spacing:.4px; color:var(--wf-tx-lo); } +.cv-plan-notes-h .wf-ico { width:12px; height:12px; color:var(--wf-perm-plan); } +.cv-plan-note { display:flex; align-items:baseline; gap:9px; font-size:12px; } +.cv-plan-note-quote { flex:0 0 auto; max-width:38%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:1px 7px; border-radius:6px; border-left:2px solid var(--wf-perm-plan); background:color-mix(in srgb, var(--wf-perm-plan) 12%, transparent); color:var(--wf-tx-lo); font-style:italic; } +.cv-plan-note-c { flex:1; min-width:0; color:var(--wf-tx-hi); line-height:1.4; } +.cv-plan-note-empty { color:var(--wf-tx-xlo); } +.cv-plan-note-del { flex:none; display:inline-flex; padding:2px; background:none; border:0; color:var(--wf-tx-xlo); cursor:pointer; border-radius:5px; } +.cv-plan-note-del:hover { color:var(--wf-err); background:color-mix(in srgb, var(--wf-err) 12%, transparent); } +.cv-plan-note-del .wf-ico { width:13px; height:13px; } + +.cv-plan-foot { display:flex; flex-direction:column; gap:9px; padding:11px 13px; border-top:1px solid var(--wf-line); } +.cv-plan-noteinput { width:100%; box-sizing:border-box; resize:vertical; min-height:38px; font:inherit; font-size:12.5px; line-height:1.45; color:var(--wf-tx-hi); background:var(--wf-bg); border:1px solid var(--wf-line-2); border-radius:9px; padding:8px 10px; } +.cv-plan-noteinput:focus { outline:none; border-color:var(--wf-perm-plan); } +.cv-plan-noteinput::placeholder { color:var(--wf-tx-xlo); } +.cv-plan-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; } +.cv-plan-hint { font-size:11px; line-height:1.4; color:var(--wf-tx-xlo); } + +/* Floating comment composer anchored at the selection (viewport-fixed). */ +.cv-plan-composer { position:fixed; z-index:60; width:min(300px, 80vw); display:flex; flex-direction:column; gap:7px; padding:9px; background:var(--wf-panel); border:1px solid var(--wf-line-2); border-radius:11px; box-shadow:0 14px 34px -14px #000, 0 4px 12px -8px #000a; } +.cv-plan-composer-input { width:100%; box-sizing:border-box; resize:vertical; min-height:44px; font:inherit; font-size:12.5px; line-height:1.45; color:var(--wf-tx-hi); background:var(--wf-bg); border:1px solid var(--wf-line-2); border-radius:8px; padding:7px 9px; } +.cv-plan-composer-input:focus { outline:none; border-color:var(--wf-perm-plan); } +.cv-plan-composer-input::placeholder { color:var(--wf-tx-xlo); } +.cv-plan-composer-foot { display:flex; align-items:center; justify-content:space-between; gap:8px; } +.cv-plan-composer-hint { font-size:10.5px; color:var(--wf-tx-xlo); } +.cv-plan-composer-btns { display:flex; align-items:center; gap:6px; } + /* The whole composer is ONE floating rounded card — like the Claude app: the text row AND the controls row live inside the same elevated block, centred on the conversation width and lifted off the window edge with a soft shadow. */ @@ -412,8 +470,9 @@ .cv-file-n { font-size:11.5px; color:var(--wf-tx-hi); flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .cv-file-d { font-size:10.5px; color:var(--wf-tx-lo); } -.cv-plan { display:flex; flex-direction:column; gap:9px; } -.cv-plan-i { display:flex; align-items:center; gap:9px; } +/* NB: a legacy `.cv-plan { display:flex; gap:9px }` (+ `.cv-plan-i`) used to live here for an + old checklist; both were dead and collided with the PlanCard's own `.cv-plan` (adding a 9px + gap between its abutting sections), so they were removed. Keep `.cv-check` — TodoList uses it. */ .cv-check { width:15px; height:15px; border-radius:50%; border:1.5px solid var(--wf-line-2); display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; color:#1a0f0a; } .cv-check.done { background:var(--wf-run); border-color:var(--wf-run); color:#06281b; } .cv-check.doing { border-color:var(--wf-att); border-style:dashed; } From 73c882060a10139fb66b837d38c9423e15882b08 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 18:06:46 +0200 Subject: [PATCH 05/91] chore(release): v0.23.0 --- CLAUDE.md | 1 + package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index afb3591..bf5b3cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,7 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell - **Transitions d'état agent** : point UNIQUE dans `useGlobalSessionEvents.ts` via `agentEventFor` (`transition.ts`, fonction pure). Règles : `awaiting_permission` false→true = attention ; `busy` true→false (vivant) = terminé. **Ne PAS dupliquer cette détection**. - **Mode Clean output** (`cleanOutput`, localStorage `tosse:display`) : repli du travail intermédiaire par round. Invariant liveness via `atomStillRunning` : si un `BackgroundTask` existe pour le tool_use, SON statut fait autorité (`running` → reste visible) ; sinon fallback `!tool_result`. **NE PAS keyer la complétion d'un sous-agent sur le seul `tool_result`** (peut arriver avant le `task_notification` terminal → fold/carte incohérents). - **Garde `<task-notification>`** : le parser se déclenche UNIQUEMENT si le texte trimmé **OUVRE** sur `<task-notification>` (anti-faux-positif — un prompt qui mentionne le tag ne doit pas déclencher le rendu). +- **Invocation de skill/slash-command** : expansée en messages `role:user` (pas de type dédié). Le body SKILL.md porte `isMeta:true` → **droppé** (`assembler.rs::ingest_user` + `history.rs::push_user`), jamais de bulle user. Un skill **model-invoqué** (`tool_use{name:"Skill"}`) rend un **chip commande dédié** (`SkillChip` + segment `skill` dans `toolGroup.ts`, calqué sur agent/workflow — casse le run, non compté comme travail) ; la voie composer (`parseSlashCommand`, header `<command-name>`) reste inchangée. Wire : `docs/claude-code-protocol.md` §3.7.1. - **Rendu Markdown** : 3 modes (Classic/Warm/Minimal, défaut `warm`), posé via `data-md-mode` sur `.md-body` → variantes CSS dans `conductor-markdown-modes.css`. Coloration syntaxique : `highlight.js` importé lazy, **langages taggés ET connus seulement** (pas de `highlightAuto` — perf + zéro fausse détection). Chip de chemin (`FileMention.tsx`) : rendu segmenté réservé aux VRAIS fichiers (gating `target != null || demo`). - **Bindings IPC** (`src/ipc/bindings.ts`) : générés par tauri-specta → **toujours regénérer et committer avant PR** (le build release ne regénère pas). - **Flight Deck — reply modal** (`FlightDeckReplyModal` + `flightdeckModalStore`) : répondre à une conversation en modale par-dessus le Flight Deck sans quitter la vue. Réutilise `ConversationPane` verbatim (3ᵉ point de montage, keyé id stable) SANS `SidePanel` (léger, par design). Store keyé id stable, INDÉPENDANT de l'activeId ; modale montée dans `App`, **gated sur la vue flightdeck** + fermée par un effet en quittant la vue → jamais la même conv montée en modale ET plein écran. Clics : TITRE de carte = plein écran, TOUT bouton d'action (« Vu » excepté) = modale, bouton « Plein écran » promeut. `FileMentionProvider` gagne un flag `inert` (propagé via `ConversationPane.inertMentions`) : dans la modale (pas d'éditeur monté) les chemins de fichiers sont du texte simple — sinon clic mort + flip silencieux du flag persisté `editorOpen`. `notify.ts` : la conv ouverte en modale compte comme « regardée » (via `useFlightdeckModal.getState().convId`) → pas de notif OS redondante. diff --git a/package.json b/package.json index d68791a..bbffef1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tosse-code", - "version": "0.22.0", + "version": "0.23.0", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e540fd0..7476833 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4713,7 +4713,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tosse-code" -version = "0.22.0" +version = "0.23.0" dependencies = [ "base64 0.22.1", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0a5fa12..b071d09 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tosse-code" -version = "0.22.0" +version = "0.23.0" description = "Tosse Code — desktop app to pilot Claude Code" authors = ["Tosse"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2abe8bf..343e354 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Tosse Code", - "version": "0.22.0", + "version": "0.23.0", "identifier": "com.tosse.desktop", "build": { "beforeDevCommand": "pnpm dev", From f998edad9c5151240fe121d532b8467cc0d3bf57 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 18:49:03 +0200 Subject: [PATCH 06/91] fix(plan): traite les findings de review de la PR #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige les 9 points remontés par la review multi-agents (rendu mode plan + skill chip). Comportement - PlanCard: `clearOwnSelection` scope la purge de sélection à la carte — ne wipe plus une sélection étrangère ailleurs à l'écran (clearDraft/commitDraft) - planStatus: corrige la contradiction doc/code (isError ⟺ permission refusée pour ExitPlanMode — comportement juste, doc rendue honnête) - CleanBlocks: un plan enterré dans le fold (historique rechargé) est désormais rendu en clair (renderFoldedWork découpe le fold à chaque plan) Perf - PlanCard: sélecteur ciblé usePendingPermission — plus de re-render à chaque permission de la session - PlanCard: rAF-throttle du handler scroll/resize du composer Cleanup / dédup - resultText.ts: flatten de tool_result partagé (planStatus + ToolSection) - util/id.ts: uid partagé (conversationsStore + planAnnotations) - store/persist.ts: loadJson/saveJson partagés (planAnnotations + workFold) - planAnnotations: withList/withNote → setNested générique + tests persist/resultText. tsc vert, 638 tests OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/features/conversation/ConductorThread.tsx | 52 ++++++++++-- src/features/conversation/PlanCard.tsx | 46 ++++++++--- src/features/conversation/ToolSection.tsx | 17 +--- src/features/conversation/planStatus.ts | 32 +++----- src/features/conversation/resultText.test.ts | 28 +++++++ src/features/conversation/resultText.ts | 26 ++++++ src/features/conversation/toolGroup.ts | 5 +- src/store/conversationStore.ts | 12 +++ src/store/conversationsStore.ts | 6 +- src/store/persist.test.ts | 27 +++++++ src/store/persist.ts | 27 +++++++ src/store/planAnnotations.ts | 81 +++++++------------ src/store/workFold.ts | 27 ++----- src/util/id.ts | 12 +++ 14 files changed, 269 insertions(+), 129 deletions(-) create mode 100644 src/features/conversation/resultText.test.ts create mode 100644 src/features/conversation/resultText.ts create mode 100644 src/store/persist.test.ts create mode 100644 src/store/persist.ts create mode 100644 src/util/id.ts diff --git a/src/features/conversation/ConductorThread.tsx b/src/features/conversation/ConductorThread.tsx index c369fe7..e183a7a 100644 --- a/src/features/conversation/ConductorThread.tsx +++ b/src/features/conversation/ConductorThread.tsx @@ -572,6 +572,52 @@ function renderSegments( * the rest fold into the block. */ const LIVE_WINDOW = 3; +/** Render a clean-output round's folded WORK. Normally one collapsible <ClaudeWorkBlock>. But a + * `plan` (ExitPlanMode) is a DECISION artifact that must never hide inside the fold — even when + * it's buried mid-round (e.g. a resumed history where an approved plan is followed by more work + * in the same group, so splitFinalMessage can't peel it into `final`). When the fold holds a + * plan, split it at each plan: every contiguous work run folds into its own block and each plan + * renders in clear between them, preserving chronology. The common (no-plan) case is byte-for-byte + * unchanged and keeps the round's single foldKey, so its remembered open/collapsed state stands. */ +function renderFoldedWork(session: string, folded: Segment[], roundKey: string): ReactNode { + if (!folded.some((s) => s.kind === "plan")) { + return ( + <ClaudeWorkBlock count={countWorkSteps(folded)} foldConv={session} foldKey={roundKey}> + {renderSegments(session, folded, false, -1)} + </ClaudeWorkBlock> + ); + } + const out: ReactNode[] = []; + let chunk: Segment[] = []; + let part = 0; + const flush = () => { + if (chunk.length === 0) return; + const c = chunk; + const idx = part++; + out.push( + <ClaudeWorkBlock + key={`fold-${idx}`} + count={countWorkSteps(c)} + foldConv={session} + foldKey={`${roundKey}#${idx}`} + > + {renderSegments(session, c, false, -1)} + </ClaudeWorkBlock>, + ); + chunk = []; + }; + for (const seg of folded) { + if (seg.kind === "plan") { + flush(); + out.push(...renderSegments(session, [seg], false, -1)); + } else { + chunk.push(seg); + } + } + flush(); + return out; +} + /** * The clean-output body of one assistant response: the fold + the trailing region + the final * message, rendered as ONE component so it has stable React identity. That stability is what @@ -652,11 +698,7 @@ function CleanBlocks({ return ( <> - {folded.length > 0 ? ( - <ClaudeWorkBlock count={countWorkSteps(folded)} foldConv={session} foldKey={roundKey}> - {renderSegments(session, folded, false, -1)} - </ClaudeWorkBlock> - ) : null} + {folded.length > 0 ? renderFoldedWork(session, folded, roundKey) : null} {visible.length > 0 ? renderSegments(session, visible, true, liveIdx) : null} {final.length > 0 ? renderSegments(session, final, false, -1) : null} </> diff --git a/src/features/conversation/PlanCard.tsx b/src/features/conversation/PlanCard.tsx index fb785ad..a1b08e6 100644 --- a/src/features/conversation/PlanCard.tsx +++ b/src/features/conversation/PlanCard.tsx @@ -14,7 +14,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { JsonValue } from "../../ipc/client"; import { field } from "../../agent/ask"; import { useAnswerPermission } from "../../ipc/useCommands"; -import { usePendingPermissions, useToolResult } from "../../store/conversationStore"; +import { usePendingPermission, useToolResult } from "../../store/conversationStore"; import { useConversationsStore } from "../../store/conversationsStore"; import { newAnnotationId, @@ -162,7 +162,7 @@ export function PlanCard({ input: JsonValue; }) { const plan = field(input, "plan") ?? ""; - const pending = usePendingPermissions(session).find((p) => p.tool_use_id === toolUseId); + const pending = usePendingPermission(session, toolUseId); const result = useToolResult(session, toolUseId); const annotations = usePlanAnnotations(session, toolUseId); const answer = useAnswerPermission(session); @@ -245,13 +245,24 @@ export function PlanCard({ republishHighlights(); }, [annotations, draft, plan]); - // Dismiss the in-progress draft: drop the composer, reset its text, and clear the native - // selection so the passage stops looking highlighted (the draft highlight is keyed off - // `draft`; the OS selection tint would linger otherwise — the "Annuler left it highlighted" bug). + // Clear the native selection ONLY when it lives inside THIS card's plan body. The Selection + // API is document-global, so an unscoped removeAllRanges would wipe a selection the user made + // elsewhere on screen (e.g. copying from another message) whenever this card tears down a draft + // — including when the plan is answered from phone/web. Scoping it keeps foreign selections. + const clearOwnSelection = () => { + const sel = window.getSelection(); + const root = bodyRef.current; + if (sel && root && sel.rangeCount > 0 && root.contains(sel.getRangeAt(0).commonAncestorContainer)) + sel.removeAllRanges(); + }; + + // Dismiss the in-progress draft: drop the composer, reset its text, and clear our own passage's + // selection so it stops looking highlighted (the draft highlight is keyed off `draft`; the OS + // selection tint would linger otherwise — the "Annuler left it highlighted" bug). const clearDraft = () => { setDraft(null); setDraftText(""); - window.getSelection()?.removeAllRanges(); + clearOwnSelection(); }; // A resolved plan can't be annotated: tear down any open draft the moment the card stops being @@ -282,12 +293,25 @@ export function PlanCard({ top = Math.max(VIEWPORT_MARGIN, rect.top - COMPOSER_H - 6); setPos({ left, top }); }; + // rAF-coalesce scroll/resize: a burst of events measures at most once per frame. `place` + // walks the plan body's text nodes (rangeFromOffsets) then getBoundingClientRect (a forced + // synchronous layout), so calling it per raw scroll event would thrash layout while a + // composer is open. The initial placement runs synchronously below. + let raf = 0; + const schedule = () => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + place(); + }); + }; place(); - window.addEventListener("scroll", place, true); - window.addEventListener("resize", place); + window.addEventListener("scroll", schedule, true); + window.addEventListener("resize", schedule); return () => { - window.removeEventListener("scroll", place, true); - window.removeEventListener("resize", place); + if (raf) cancelAnimationFrame(raf); + window.removeEventListener("scroll", schedule, true); + window.removeEventListener("resize", schedule); }; }, [draft]); @@ -324,7 +348,7 @@ export function PlanCard({ addAnn(session, toolUseId, ann); setDraft(null); setDraftText(""); - window.getSelection()?.removeAllRanges(); + clearOwnSelection(); }; const accept = () => { diff --git a/src/features/conversation/ToolSection.tsx b/src/features/conversation/ToolSection.tsx index d536b2d..c00c8ab 100644 --- a/src/features/conversation/ToolSection.tsx +++ b/src/features/conversation/ToolSection.tsx @@ -16,6 +16,7 @@ import { Ico } from "../../ui/kit"; import { DiffView } from "./DiffView"; import { MentionPathChip } from "./FileMention"; import { QuestionnaireSummary } from "./QuestionnaireAsk"; +import { resultContentText } from "./resultText"; import { StreamMarkdown } from "./StreamMarkdown"; import { ToolResultBody } from "./ToolResultBody"; import { toolMeta } from "./toolMeta"; @@ -36,19 +37,9 @@ export interface StepResult { isError: boolean; } -/** Flatten a tool_result content to text, for the line-count summaries. */ -export function resultContentText(content: JsonValue): string | null { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const b of content) { - if (b && typeof b === "object" && !Array.isArray(b) && typeof (b as Record<string, JsonValue>).text === "string") - parts.push((b as Record<string, JsonValue>).text as string); - } - return parts.length ? parts.join("\n") : null; - } - return null; -} +// Re-exported for callers that historically imported it from here (the implementation now +// lives in the framework-free ./resultText so planStatus can share it without pulling React). +export { resultContentText }; /** Does this tool have an expandable detail body (diff / output / questionnaire / plan)? */ function hasDetailFor(name: string, input: JsonValue, result: StepResult | undefined): boolean { diff --git a/src/features/conversation/planStatus.ts b/src/features/conversation/planStatus.ts index 8d1c040..8abe2d6 100644 --- a/src/features/conversation/planStatus.ts +++ b/src/features/conversation/planStatus.ts @@ -4,37 +4,25 @@ import type { JsonValue } from "../../ipc/client"; import type { PlanAnnotation } from "../../store/planAnnotations"; +import { resultContentText } from "./resultText"; /** The plan's outcome as read from its settled tool_result (the live "pending" state is * decided by the presence of an open permission, not here). `unknown` = a result we can't * classify (never mislabel it approved/rejected). */ export type PlanResultDecision = "approved" | "rejected" | "unknown"; -/** Flatten a tool_result content (string or content-block array) to plain text. */ -function flatten(content: JsonValue): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const b of content) { - if (b && typeof b === "object" && !Array.isArray(b)) { - const t = (b as Record<string, JsonValue>).text; - if (typeof t === "string") parts.push(t); - } - } - return parts.join("\n"); - } - return ""; -} - /** - * Classify a settled ExitPlanMode result. The CLI returns, on approval: - * "User has approved your plan. You can now start coding. …" - * A denied permission comes back as an ERROR result carrying our deny message (or the CLI's - * own rejection phrasing). Anything else stays `unknown` so the card shows a neutral state - * rather than a wrong badge. + * Classify a settled ExitPlanMode result into approved / rejected / unknown. + * + * The CLI returns, on approval: "User has approved your plan. You can now start coding. …". + * ExitPlanMode is a permission-gated tool that does no work of its own, so the ONLY way it + * yields an error result is a DENIED permission (the deny carries our feedback message, or the + * CLI's own rejection phrasing) — hence `isError` is deterministically a rejection here. A + * NON-error result we recognize as neither an approval nor an explicit rejection stays + * `unknown`, so the card shows a neutral state rather than a wrong badge. */ export function planResultDecision(content: JsonValue, isError: boolean): PlanResultDecision { - const text = flatten(content).toLowerCase(); + const text = (resultContentText(content) ?? "").toLowerCase(); if (!isError && /approved your plan|has approved the plan|approved the plan/.test(text)) return "approved"; if (isError) return "rejected"; diff --git a/src/features/conversation/resultText.test.ts b/src/features/conversation/resultText.test.ts new file mode 100644 index 0000000..31745a2 --- /dev/null +++ b/src/features/conversation/resultText.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import type { JsonValue } from "../../ipc/client"; +import { resultContentText } from "./resultText"; + +describe("resultContentText", () => { + it("passes a plain string through", () => { + expect(resultContentText("hello")).toBe("hello"); + }); + + it("joins the .text of each text block in a content-block array", () => { + const content = [ + { type: "text", text: "one" }, + { type: "text", text: "two" }, + ] as unknown as JsonValue; + expect(resultContentText(content)).toBe("one\ntwo"); + }); + + it("ignores non-text blocks and returns null when none carry text", () => { + const content = [{ type: "image", source: {} }] as unknown as JsonValue; + expect(resultContentText(content)).toBeNull(); + }); + + it("returns null for an empty array and for non-string/non-array content", () => { + expect(resultContentText([] as unknown as JsonValue)).toBeNull(); + expect(resultContentText(null)).toBeNull(); + expect(resultContentText(42 as unknown as JsonValue)).toBeNull(); + }); +}); diff --git a/src/features/conversation/resultText.ts b/src/features/conversation/resultText.ts new file mode 100644 index 0000000..49dc743 --- /dev/null +++ b/src/features/conversation/resultText.ts @@ -0,0 +1,26 @@ +// Flatten a tool_result content to plain text. Shared by ToolSection (line-count summaries) +// and planStatus (classifying an ExitPlanMode result) so the two never diverge on the wire's +// `content` shape. Framework-free (only a type import) so it stays trivially unit-testable. + +import type { JsonValue } from "../../ipc/client"; + +/** Flatten a tool_result content (a string OR a content-block array) to plain text, + * concatenating the `.text` of each text block. Returns null when there is no text (empty + * array / non-text content) so callers can distinguish "no textual result". */ +export function resultContentText(content: JsonValue): string | null { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const b of content) { + if ( + b && + typeof b === "object" && + !Array.isArray(b) && + typeof (b as Record<string, JsonValue>).text === "string" + ) + parts.push((b as Record<string, JsonValue>).text as string); + } + return parts.length ? parts.join("\n") : null; + } + return null; +} diff --git a/src/features/conversation/toolGroup.ts b/src/features/conversation/toolGroup.ts index 196c817..a7e13b2 100644 --- a/src/features/conversation/toolGroup.ts +++ b/src/features/conversation/toolGroup.ts @@ -230,8 +230,9 @@ export function splitFinalMessage(segments: Segment[]): { // A trailing `plan` (ExitPlanMode) is peeled too: the proposed plan is a decision artifact, not // intermediate work — it must stay in clear even under clean output, especially while it awaits // approval (the agent pauses right after it). A plan buried mid-response (e.g. a resumed history - // where the approved plan is followed by more work in the same group) stays in the fold — the - // scan stops at the trailing run, which is acceptable for that settled-history case. + // where the approved plan is followed by more work in the same group) is NOT peeled here — the + // scan stops at the trailing run — but it is still shown in clear: CleanBlocks splits the fold + // at each plan (see renderFoldedWork), so a buried plan is never hidden inside the work block. let i = segments.length; while ( i > 0 && diff --git a/src/store/conversationStore.ts b/src/store/conversationStore.ts index fe271cb..3577745 100644 --- a/src/store/conversationStore.ts +++ b/src/store/conversationStore.ts @@ -769,6 +769,18 @@ export const usePendingPermissions = ( useShallow((s) => s.sessions[session]?.pendingPermissions ?? EMPTY_PERMS), ); +/** Subscribe to the pending permission for ONE tool_use, or undefined. Unlike + * {@link usePendingPermissions} this does not observe the whole array: add/remove preserve the + * existing request object references, so `.find` returns a stable ref (Object.is-equal) and a + * permission raised/answered for another tool in the session never re-renders this subscriber. */ +export const usePendingPermission = ( + session: string, + toolUseId: string, +): PermissionRequestPayload | undefined => + useConversationStore((s) => + s.sessions[session]?.pendingPermissions.find((p) => p.tool_use_id === toolUseId), + ); + export const useSubThread = ( session: string, parentToolUseId: string, diff --git a/src/store/conversationsStore.ts b/src/store/conversationsStore.ts index 11618fb..d2a612d 100644 --- a/src/store/conversationsStore.ts +++ b/src/store/conversationsStore.ts @@ -19,6 +19,7 @@ // existing conversation — reading the transcript is what fills it back in. import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; +import { uid } from "../util/id"; import { commands } from "../ipc/client"; import type { ConversationItem, ConversationRecord, DiskConversation, PermissionMode, RepoRecord } from "../ipc/client"; import type { ReminderKind } from "../agent/status"; @@ -141,11 +142,6 @@ export function repoName(path: string): string { return parts[parts.length - 1] || path; } -function uid(): string { - if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); - return `id-${Date.now()}-${Math.round(Math.random() * 1e9)}`; -} - function deriveName(text: string): string { const t = text.trim().replace(/\s+/g, " "); if (!t) return DEFAULT_CONV_NAME; diff --git a/src/store/persist.test.ts b/src/store/persist.test.ts new file mode 100644 index 0000000..281f039 --- /dev/null +++ b/src/store/persist.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { loadJson, saveJson } from "./persist"; + +const KEY = "tosse:test:persist"; + +describe("persist", () => { + beforeEach(() => localStorage.clear()); + + it("loadJson returns the fallback when the key is absent", () => { + expect(loadJson(KEY, { a: 1 })).toEqual({ a: 1 }); + }); + + it("saveJson → loadJson round-trips an object", () => { + saveJson(KEY, { x: [1, 2], y: "z" }); + expect(loadJson(KEY, {})).toEqual({ x: [1, 2], y: "z" }); + }); + + it("loadJson returns the fallback on corrupt JSON", () => { + localStorage.setItem(KEY, "{not json"); + expect(loadJson(KEY, { fallback: true })).toEqual({ fallback: true }); + }); + + it("loadJson returns the fallback when the stored value is not an object (e.g. a bare number)", () => { + localStorage.setItem(KEY, "42"); + expect(loadJson(KEY, { ok: 1 })).toEqual({ ok: 1 }); + }); +}); diff --git a/src/store/persist.ts b/src/store/persist.ts new file mode 100644 index 0000000..b96482d --- /dev/null +++ b/src/store/persist.ts @@ -0,0 +1,27 @@ +// Shared localStorage persistence for the lightweight, pure-UI Zustand stores (plan +// annotations, work-fold, …) that live OUTSIDE the SQLite core. Every such store used to +// repeat the same try/catch load/save boilerplate; this is the single copy. Best-effort by +// design: a read that can't parse falls back, a write that can't persist (quota / disabled +// storage) is swallowed — UI prefs are never worth throwing over. + +/** Read and JSON-parse a localStorage value, returning `fallback` when it is absent, + * unparseable, or not a plain object. */ +export function loadJson<T>(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" ? (parsed as T) : fallback; + } catch { + return fallback; + } +} + +/** JSON-serialize and write a value to localStorage, swallowing quota / disabled-storage errors. */ +export function saveJson(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* quota / disabled storage — best-effort, ignore */ + } +} diff --git a/src/store/planAnnotations.ts b/src/store/planAnnotations.ts index 0d1c1f5..45c4632 100644 --- a/src/store/planAnnotations.ts +++ b/src/store/planAnnotations.ts @@ -1,6 +1,6 @@ // User annotations on a proposed plan (ExitPlanMode) — a highlighted span of the rendered // plan text plus a comment, PLUS a per-plan general note, keyed by conversation id → tool_use -// id. Persisted to localStorage with the same lightweight pattern as workFold.ts / display.ts +// id. Persisted to localStorage via the shared {@link loadJson}/{@link saveJson} helpers // (pure UI state, kept out of the SQLite core — no schema migration). // // Why a store and not local component state: the conversation pane is remounted per @@ -17,6 +17,8 @@ // + notes and the undo re-seeds them — this is deliberate user content, unlike the transient // UI caches cleared the same way (see conversationsStore removeConversation/undo). import { create } from "zustand"; +import { uid } from "../util/id"; +import { loadJson, saveJson } from "./persist"; const ANN_KEY = "tosse:planannotations"; const NOTE_KEY = "tosse:plannotes"; @@ -44,30 +46,9 @@ export interface PlanConvSnapshot { notes: Record<string, string>; } -function load<T>(key: string): T { - try { - const raw = localStorage.getItem(key); - if (!raw) return {} as T; - const parsed = JSON.parse(raw) as unknown; - return parsed && typeof parsed === "object" ? (parsed as T) : ({} as T); - } catch { - return {} as T; - } -} - -function save(key: string, map: unknown): void { - try { - localStorage.setItem(key, JSON.stringify(map)); - } catch { - /* quota / disabled storage — best-effort, ignore */ - } -} - /** A unique enough id for a local annotation (no collisions within one plan in practice). */ export function newAnnotationId(): string { - const c = globalThis.crypto as Crypto | undefined; - if (c && typeof c.randomUUID === "function") return c.randomUUID(); - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return uid(); } const EMPTY: PlanAnnotation[] = []; @@ -82,39 +63,39 @@ interface PlanAnnotationsState { clearAll: () => void; } -function withList( - map: AnnMap, +// Immutably set (or, when `value` is undefined, delete) `map[conv][key]`, pruning an emptied +// inner map so the outer map only ever holds conversations with live content. Shared by the +// annotations map and the notes map — the two differ only in what counts as "empty" (an empty +// list vs an empty string), which the caller resolves by passing `undefined` to delete. +function setNested<V>( + map: Record<string, Record<string, V>>, conv: string, - toolUseId: string, - next: PlanAnnotation[], -): AnnMap { + key: string, + value: V | undefined, +): Record<string, Record<string, V>> { const convMap = { ...(map[conv] ?? {}) }; - if (next.length === 0) delete convMap[toolUseId]; - else convMap[toolUseId] = next; + if (value === undefined) delete convMap[key]; + else convMap[key] = value; const out = { ...map }; if (Object.keys(convMap).length === 0) delete out[conv]; else out[conv] = convMap; return out; } -function withNote(map: NoteMap, conv: string, toolUseId: string, note: string): NoteMap { - const convMap = { ...(map[conv] ?? {}) }; - if (note === "") delete convMap[toolUseId]; - else convMap[toolUseId] = note; - const out = { ...map }; - if (Object.keys(convMap).length === 0) delete out[conv]; - else out[conv] = convMap; - return out; -} +const withList = (map: AnnMap, conv: string, toolUseId: string, next: PlanAnnotation[]): AnnMap => + setNested(map, conv, toolUseId, next.length === 0 ? undefined : next); + +const withNote = (map: NoteMap, conv: string, toolUseId: string, note: string): NoteMap => + setNested(map, conv, toolUseId, note === "" ? undefined : note); export const usePlanAnnotationsStore = create<PlanAnnotationsState>((set) => ({ - byConv: load<AnnMap>(ANN_KEY), - notes: load<NoteMap>(NOTE_KEY), + byConv: loadJson<AnnMap>(ANN_KEY, {}), + notes: loadJson<NoteMap>(NOTE_KEY, {}), add: (conv, toolUseId, ann) => set((s) => { const list = s.byConv[conv]?.[toolUseId] ?? EMPTY; const next = withList(s.byConv, conv, toolUseId, [...list, ann]); - save(ANN_KEY, next); + saveJson(ANN_KEY, next); return { byConv: next }; }), remove: (conv, toolUseId, id) => @@ -122,13 +103,13 @@ export const usePlanAnnotationsStore = create<PlanAnnotationsState>((set) => ({ const list = s.byConv[conv]?.[toolUseId]; if (!list) return s; const next = withList(s.byConv, conv, toolUseId, list.filter((a) => a.id !== id)); - save(ANN_KEY, next); + saveJson(ANN_KEY, next); return { byConv: next }; }), setNote: (conv, toolUseId, note) => set((s) => { const next = withNote(s.notes, conv, toolUseId, note); - save(NOTE_KEY, next); + saveJson(NOTE_KEY, next); return { notes: next }; }), clearConversation: (conv) => @@ -140,14 +121,14 @@ export const usePlanAnnotationsStore = create<PlanAnnotationsState>((set) => ({ const notes = { ...s.notes }; delete byConv[conv]; delete notes[conv]; - save(ANN_KEY, byConv); - save(NOTE_KEY, notes); + saveJson(ANN_KEY, byConv); + saveJson(NOTE_KEY, notes); return { byConv, notes }; }), clearAll: () => set(() => { - save(ANN_KEY, {}); - save(NOTE_KEY, {}); + saveJson(ANN_KEY, {}); + saveJson(NOTE_KEY, {}); return { byConv: {}, notes: {} }; }), })); @@ -189,8 +170,8 @@ export function restorePlanAnnotations(conv: string, snap: PlanConvSnapshot | nu const notes = { ...s.notes }; if (Object.keys(snap.ann).length > 0) byConv[conv] = snap.ann; if (Object.keys(snap.notes).length > 0) notes[conv] = snap.notes; - save(ANN_KEY, byConv); - save(NOTE_KEY, notes); + saveJson(ANN_KEY, byConv); + saveJson(NOTE_KEY, notes); return { byConv, notes }; }); } diff --git a/src/store/workFold.ts b/src/store/workFold.ts index adf0001..2b791b4 100644 --- a/src/store/workFold.ts +++ b/src/store/workFold.ts @@ -1,7 +1,7 @@ // Open/collapsed state of the "Travail de Claude" fold blocks (clean-output mode), -// keyed by conversation id → round key → open. Persisted to localStorage with the -// same lightweight pattern as display.ts / notifications.ts (not the SQLite metadata -// store — this is pure UI state, so it stays out of the Rust core, no schema migration). +// keyed by conversation id → round key → open. Persisted to localStorage via the shared +// {@link loadJson}/{@link saveJson} helpers (not the SQLite metadata store — this is pure UI +// state, so it stays out of the Rust core, no schema migration). // // Why a store and not a plain React useState in ClaudeWorkBlock: the conversation pane // is remounted per conversation (`key={conv.id}` in ConductorConversation), so any local @@ -12,6 +12,7 @@ // stable across reloads), so a persisted entry re-associates with the right block on // return; an orphaned key just falls back to collapsed (the default), which is harmless. import { create } from "zustand"; +import { loadJson, saveJson } from "./persist"; const STORAGE_KEY = "tosse:workfold"; @@ -19,24 +20,8 @@ const STORAGE_KEY = "tosse:workfold"; // entries the user actually touched. type FoldMap = Record<string, Record<string, boolean>>; -function load(): FoldMap { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as unknown; - return parsed && typeof parsed === "object" ? (parsed as FoldMap) : {}; - } catch { - return {}; - } -} - -function save(map: FoldMap): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); - } catch { - /* quota / disabled storage — best-effort, ignore */ - } -} +const load = (): FoldMap => loadJson<FoldMap>(STORAGE_KEY, {}); +const save = (map: FoldMap): void => saveJson(STORAGE_KEY, map); interface WorkFoldState { open: FoldMap; diff --git a/src/util/id.ts b/src/util/id.ts new file mode 100644 index 0000000..38937c6 --- /dev/null +++ b/src/util/id.ts @@ -0,0 +1,12 @@ +// A locally-unique id generator, shared by every client-side store that mints its own ids +// (repos, conversations, plan annotations). Prefers crypto.randomUUID; falls back to a +// timestamp + random suffix only where the Web Crypto API is unavailable (never in the Tauri +// webview in practice). Kept in one place so any future hardening (entropy, collision guard) +// lands once. + +/** A locally-unique id: `crypto.randomUUID()` when available, else a timestamp+random fallback. */ +export function uid(): string { + const c = globalThis.crypto as Crypto | undefined; + if (c && typeof c.randomUUID === "function") return c.randomUUID(); + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} From 5a4ac436d54a50d5c9de6dd544b24d3b91e62f12 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 21:54:16 +0200 Subject: [PATCH 07/91] =?UTF-8?q?fix(conversation):=20drop=20le=20body=20d?= =?UTF-8?q?e=20skill=20fant=C3=B4me=20en=20live=20(isMeta=20absent=20du=20?= =?UTF-8?q?wire=20live)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un skill auto-invoqué par le modèle (outil Skill) réaffichait le body du SKILL.md comme une fausse bulle user, EN PLUS du SkillChip. Le fix précédent (2247ebd6) droppait la ligne `isMeta:true`, ce qui marche au reload mais PAS en live. Cause racine confirmée par capture live (nouveau probe `control.rs::live_capture_skill_body_replay`) : le CLI n'ajoute `isMeta:true` QUE dans le transcript persisté ; sur le stdout LIVE la ligne du body est nue (`isMeta`/`isReplay`/`sourceToolUseID` tous absents). La garde `is_meta` ne se déclenche donc jamais en live → bug live-only (disparaissait au redémarrage). Fix socle-only (assembler) : on arme `skill_invocation_pending` sur un tool_use Skill (`ingest_assistant`), on droppe dans `ingest_user` la ligne texte qui ouvre sur le prefix boilerplate « Base directory for this skill: » tant que c'est armé, et on désarme en fin de tour (`ingest_result`). Double garde (flag armé ET prefix) → jamais de faux-drop d'un vrai message. Front inchangé. - protocol.rs : champ UserMsg.source_tool_use_id (présent on-disk, absent live) - fixture capture_skill_live.jsonl (vraie forme live, sans isMeta) + 2 tests (drop live + non-régression « ne pas avaler le tour suivant ») - docs/claude-code-protocol.md §3.7.1 : écart live-vs-disk sur isMeta documenté - probe live #[ignore] conservé (instrument de re-capture, comme les autres) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/claude-code-protocol.md | 32 ++-- src-tauri/src/supervisor/assembler.rs | 102 ++++++++++++ src-tauri/src/supervisor/control.rs | 146 ++++++++++++++++++ .../fixtures/capture_skill_live.jsonl | 4 + src-tauri/src/supervisor/protocol.rs | 10 ++ 5 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 src-tauri/src/supervisor/fixtures/capture_skill_live.jsonl diff --git a/docs/claude-code-protocol.md b/docs/claude-code-protocol.md index 839ea5c..ad22cea 100644 --- a/docs/claude-code-protocol.md +++ b/docs/claude-code-protocol.md @@ -298,18 +298,30 @@ and a model-invoked skill expand into ordinary `user` messages (never `tool_resu **The MODEL invokes a skill (the `Skill` tool)** → an assistant `tool_use{name:"Skill", input:{skill,args?}}`, then: 1. a `user` `tool_result` **ack** (`"Launching skill: <skill>"`), `isMeta` absent; -2. the **same `isMeta:true` body** as above. +2. the **same body** as above. There is **NO `<command-*>` header** here — the `Skill` tool_use *is* the header. -**Handling (both live `assembler.rs::ingest_user` and reload `history.rs::push_user`):** an -`isMeta:true` user line is **dropped** — it's injected boilerplate (also covers system-reminders -and the "while you were working" wrapper), never a real turn. So the SKILL.md **body never -renders as a user bubble**. The visible trace is: for a typed command, the header string → -rendered as a clean `.cv-cmd` chip (`userText.tsx`); for a model invocation, the `Skill` -tool_use → rendered as a dedicated command chip (`SkillChip`, from `input.skill`). ⚠️ The -`isMeta` drop is what keeps the body hidden — do NOT surface `isMeta` user lines. Fixture: -`fixtures/capture_skill.jsonl`; regression tests: `skill_body_user_line_is_dropped`, -`skill_body_line_is_skipped_on_restore`, `skill_invocation_fixture_surfaces_tool_use_not_body`. +**⚠️ LIVE vs ON-DISK divergence on the body's `isMeta` (task 7e69f8ee, captured via +`live_capture_skill_body_replay`):** the body line carries `isMeta:true` **only in the +PERSISTED transcript**. On the **LIVE stdout** stream-json the CLI **OMITS `isMeta`** (and +`sourceToolUseID`) on that exact line — it is a *bare* `user` text line with no distinguishing +metadata. So a guard that drops only `isMeta:true` works on reload but NOT live. + +**Handling:** +- **Reload** (`history.rs::push_user`): the on-disk `isMeta:true` line is dropped like any meta + line (also covers system-reminders and the "while you were working" wrapper). +- **Live** (`assembler.rs::ingest_user`): `isMeta` is absent, so we drop the body by its + boilerplate **prefix** `Base directory for this skill:` **while a `Skill` tool_use is armed** + this turn (`skill_invocation_pending`, set in `ingest_assistant`, reset on `result`). Gated on + BOTH so a real user turn is never swallowed. + +So the SKILL.md **body never renders as a user bubble** (live OR reload). The visible trace is: +for a typed command, the header string → a clean `.cv-cmd` chip (`userText.tsx`); for a model +invocation, the `Skill` tool_use → a dedicated `SkillChip` (from `input.skill`). Fixtures: +`fixtures/capture_skill.jsonl` (on-disk, `isMeta:true`) + `fixtures/capture_skill_live.jsonl` +(live shape, NO `isMeta`); regression tests: `skill_body_user_line_is_dropped`, +`skill_body_line_is_skipped_on_restore`, `skill_invocation_fixture_surfaces_tool_use_not_body`, +`skill_body_live_line_without_ismeta_is_dropped`, `skill_body_drop_does_not_swallow_real_next_turn`. ### 3.8 `parent_tool_use_id` = sub-agent (Task) grouping (`confirmed`) diff --git a/src-tauri/src/supervisor/assembler.rs b/src-tauri/src/supervisor/assembler.rs index 12c90bd..e6efc21 100644 --- a/src-tauri/src/supervisor/assembler.rs +++ b/src-tauri/src/supervisor/assembler.rs @@ -63,6 +63,18 @@ pub struct Assembler { /// → suppressed. A remote (phone/web) turn carries a uuid we never sent → surfaced. /// Consumed on match (removed), so the set self-bounds to in-flight sends. sent_user_uuids: HashSet<String>, + /// Armed when a model-invoked `Skill` tool_use is seen; consumed in `ingest_user`. + /// A `Skill` tool_use expands into an INJECTED `user` line carrying the SKILL.md body + /// (it opens with `Base directory for this skill:`). ON DISK that line is flagged + /// `isMeta:true` and dropped by the `is_meta` guard (mirrored by `history.rs`). But on + /// the LIVE stdout wire the CLI OMITS `isMeta` (and `sourceToolUseID`) on it — proven + /// by a live capture (`live_capture_skill_body_replay`) — so the `is_meta` guard never + /// fires live and the body leaked as a fake user bubble. This was LIVE-ONLY: a reload + /// reads the on-disk `isMeta:true` line and drops it, which is why it looked "fixed but + /// still happening". While armed we drop that body by its boilerplate prefix. Reset at + /// 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 last-announced friendly labels for the three controls (see [`Assembler`]). @@ -760,6 +772,12 @@ impl Assembler { if name == "SendMessage" { self.resume_agent_via_send_message(input, out); } + // A model-invoked skill: the CLI will inject the SKILL.md body as a bare + // `user` text line that — LIVE — lacks the `isMeta` flag we'd normally drop + // it by. Arm the drop; it's consumed in `ingest_user`. See the field doc. + if name == "Skill" { + self.skill_invocation_pending = true; + } } } out.push(SessionEvent::Item(ConversationItem::AssistantMessage { @@ -855,6 +873,16 @@ impl Assembler { _ => {} } if !text.trim().is_empty() { + // Drop the INJECTED SKILL.md body of a model-invoked skill. On disk it's + // `isMeta:true` (already dropped above); LIVE the CLI omits `isMeta`, so we + // recognise it by its boilerplate prefix WHILE a `Skill` invocation is in + // flight this turn. Gated on BOTH (armed flag AND prefix) so a real user turn + // is never swallowed — the visible trace is the `Skill` tool_use (SkillChip). + if self.skill_invocation_pending + && text.trim_start().starts_with("Base directory for this skill:") + { + return; + } let uuid = u.uuid.clone().unwrap_or_default(); // Suppress the echo of a turn WE sent (`--replay-user-messages` returns it // with the uuid we stamped) — the UI shows our own messages optimistically. @@ -877,6 +905,9 @@ impl Assembler { self.state.activity = None; self.state.awaiting_permission = false; self.current_message_id = None; + // Disarm the skill-body drop at end-of-turn: a real user turn can only arrive after + // this `result`, so the guard must never straddle into the next turn. + self.skill_invocation_pending = false; // Authoritative end-of-turn context fill + window size. A multi-call turn's // top-level `usage` can aggregate its `iterations[]`, so prefer the LAST // iteration — the final model call's prompt = current context occupancy. @@ -1189,6 +1220,12 @@ mod tests { const CAPTURE: &str = include_str!("fixtures/capture_text.jsonl"); const CAPTURE_SKILL: &str = include_str!("fixtures/capture_skill.jsonl"); + /// The LIVE stdout shape of a model-invoked skill (captured via + /// `live_capture_skill_body_replay`): the injected SKILL.md body carries NO `isMeta` + /// (the CLI only adds it when persisting), so the `is_meta` guard can't drop it — only + /// the armed skill-body drop can. This is the fixture the ON-DISK `capture_skill.jsonl` + /// could NOT model. + const CAPTURE_SKILL_LIVE: &str = include_str!("fixtures/capture_skill_live.jsonl"); #[test] fn assembles_fixture_into_normalized_events() { @@ -1269,6 +1306,71 @@ mod tests { ); } + /// REGRESSION (task 7e69f8ee): the LIVE wire of a model-invoked skill. The injected + /// SKILL.md body arrives WITHOUT `isMeta` (the CLI only adds it when persisting to the + /// transcript — proven by `live_capture_skill_body_replay`), so the `is_meta` guard the + /// prior fix relied on NEVER fires live and the body leaked as a fake user bubble (bug + /// was LIVE-ONLY: a reload read the on-disk `isMeta:true` line and dropped it). The + /// armed skill-body drop (a `Skill` tool_use + the boilerplate prefix) must suppress it. + /// This fixture has NO `isMeta`, so it FAILS the prior fix and PASSES this one. + #[test] + fn skill_body_live_line_without_ismeta_is_dropped() { + let mut asm = Assembler::new(); + let mut saw_skill_tool_use = false; + let mut user_messages = 0; + for line in CAPTURE_SKILL_LIVE.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let msg: CliMessage = serde_json::from_str(line).unwrap(); + for ev in asm.ingest(&msg) { + match ev { + SessionEvent::Item(ConversationItem::AssistantMessage { blocks, .. }) => { + saw_skill_tool_use |= blocks + .iter() + .any(|b| matches!(b, NormalizedBlock::ToolUse { name, .. } if name == "Skill")); + } + SessionEvent::Item(ConversationItem::UserMessage { .. }) => user_messages += 1, + _ => {} + } + } + } + assert!(saw_skill_tool_use, "the Skill tool_use must still surface (the SkillChip)"); + assert_eq!( + user_messages, 0, + "the LIVE SKILL.md body (no isMeta) must be dropped, never a fake user bubble" + ); + } + + /// The armed skill-body drop is GATED: it must not swallow a real user turn that merely + /// arrives after a skill invocation. A genuine turn only comes after the `result` that + /// disarms the guard — and even in-turn, only the exact boilerplate prefix is dropped. + #[test] + fn skill_body_drop_does_not_swallow_real_next_turn() { + let mut asm = Assembler::new(); + // Drive a full skill invocation (arms, then disarms on `result`). + for line in CAPTURE_SKILL_LIVE.lines().filter(|l| !l.trim().is_empty()) { + let msg: CliMessage = serde_json::from_str(line.trim()).unwrap(); + asm.ingest(&msg); + } + // A real user turn in the NEXT turn must surface normally. + let real: CliMessage = serde_json::from_value(serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": [{"type": "text", "text": "thanks, now do X"}]}, + "uuid": "u-real-next" + })) + .unwrap(); + let events = asm.ingest(&real); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::Item(ConversationItem::UserMessage { text, .. }) if text == "thanks, now do X" + )), + "a real user turn after a skill invocation must NOT be swallowed by the drop guard" + ); + } + #[test] fn normalizes_assistant_tool_use_blocks() { let assistant = serde_json::json!({ diff --git a/src-tauri/src/supervisor/control.rs b/src-tauri/src/supervisor/control.rs index 77887d0..1d0a6d3 100644 --- a/src-tauri/src/supervisor/control.rs +++ b/src-tauri/src/supervisor/control.rs @@ -1281,4 +1281,150 @@ mod tests { logln!("log written to {log_path}"); transport.shutdown().await; } + + /// Live capture: does a MODEL-invoked skill's SKILL.md body leak as a user turn? + /// The body arrives as an `isMeta:true` user line (dropped by `ingest_user` L805 + + /// `history.rs::push_user`). But `--replay-user-messages` (unconditional) ALSO + /// re-emits user lines on stdout — if the replay echo of the injected body drops + /// `isMeta`, the L805 guard misses it and the body surfaces as a fake user bubble. + /// This is LIVE-ONLY: a reload reads the on-disk `isMeta:true` line and drops it, so + /// the bug vanishes on restart. This probe logs every `user` line's + /// (uuid, isMeta, isReplay) so we can see the body's ORIGINAL vs REPLAY shape and + /// pick the fix (drop-by-isMeta is not enough; likely track the meta uuid like + /// `sent_user_uuids`). Ignored by default (spawns claude: network + auth). Run with: + /// cargo test --lib --ignored live_capture_skill_body_replay -- --nocapture + #[tokio::test] + #[ignore = "spawns the real claude binary; captures the skill-body isMeta/replay wire"] + async fn live_capture_skill_body_replay() { + use crate::supervisor::protocol::CliMessage; + use crate::supervisor::transport::{self, SpawnConfig, Transport}; + use std::io::Write as _; + use std::time::{Duration, Instant}; + + let log_path = std::env::var("TOSSE_SKILL_LOG") + .unwrap_or_else(|_| "/tmp/skill_body_capture.log".to_string()); + let mut file = std::fs::File::create(&log_path).ok(); + macro_rules! logln { ($($a:tt)*) => {{ + let s = format!($($a)*); + eprintln!("{s}"); + if let Some(f) = file.as_mut() { let _ = writeln!(f, "{s}"); } + }}; } + + // A throwaway project skill in a temp cwd → zero side effects, deterministic + // availability. Claude discovers `.claude/skills/<name>/SKILL.md` walking up from cwd. + let tmp = std::env::temp_dir().join(format!("tosse-skill-probe-{}", uuid::Uuid::new_v4())); + let skill_dir = tmp.join(".claude/skills/probe"); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: probe\ndescription: Trivial probe skill. Use when asked to run the probe skill.\n---\n\n# Probe skill\n\nReply with exactly the word PROBE_OK and end your turn. Do nothing else.\n", + ) + .expect("write SKILL.md"); + + let mut cfg = SpawnConfig::new(tmp.clone()); + cfg.allowed_tools = ["Skill"].iter().map(|s| s.to_string()).collect(); + let (mut transport, mut rx) = Transport::spawn(cfg).expect("claude should spawn"); + transport + .send_line(initialize_request("cap-init")) + .expect("send initialize"); + + // Concatenate a user `message` Value's text blocks (or string content). + fn user_text(msg: &Value) -> String { + match msg.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(a)) => a + .iter() + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect::<Vec<_>>() + .join(" "), + _ => String::new(), + } + } + + transport + .send_line(transport::user_message( + "Use the Skill tool to invoke the skill named \"probe\", then do exactly what it says.", + &uuid::Uuid::new_v4().to_string(), + )) + .expect("send prompt"); + + // Each time the skill-body text appears on a `user` line, record its shape. + let mut body_sightings: Vec<(String, Option<bool>, Option<bool>)> = Vec::new(); + let mut skill_tool_use_id: Option<String> = None; + let mut body_src_matches = false; + let mut results_seen = 0; + + let deadline = Instant::now() + Duration::from_secs(180); + while Instant::now() < deadline { + let msg = match tokio::time::timeout(Duration::from_secs(90), rx.recv()).await { + Ok(Some(m)) => m, + Ok(None) => { logln!("<stdout closed>"); break; } + Err(_) => { logln!("<recv timeout>"); break; } + }; + match msg { + CliMessage::ControlRequest(v) => { + if let Some((rid, body)) = parse_inbound_control(&v) { + match body { + Ok(InboundControl::CanUseTool(req)) => { + logln!("[perm] allow {} tool_use_id={}", req.tool_name, req.tool_use_id); + let _ = transport.send_line(permission_allow_response( + &rid, &req.tool_use_id, req.input.clone(), + )); + } + _ => { let _ = transport.send_line(control_error_response(&rid, "unsupported")); } + } + } + } + CliMessage::Assistant(a) => { + if let Some(arr) = a.message.get("content").and_then(Value::as_array) { + for b in arr { + if b.get("type").and_then(Value::as_str) == Some("tool_use") + && b.get("name").and_then(Value::as_str) == Some("Skill") + { + let id = b.get("id").and_then(Value::as_str).unwrap_or("").to_string(); + logln!("[assistant Skill tool_use] id={id} input={}", + serde_json::to_string(&b.get("input").cloned().unwrap_or(Value::Null)).unwrap_or_default()); + skill_tool_use_id = Some(id); + } + } + } + } + CliMessage::User(u) => { + let txt = user_text(&u.message); + let head: String = txt.chars().take(80).collect(); + logln!("[user] uuid={:?} isMeta={:?} isReplay={:?} sourceToolUseID={:?} text={:?}", + u.uuid, u.is_meta, u.is_replay, u.source_tool_use_id, head); + if txt.contains("Base directory for this skill") { + let matches_skill = u.source_tool_use_id.is_some() + && u.source_tool_use_id == skill_tool_use_id; + logln!(" ^^^ SKILL BODY line: uuid={:?} isMeta={:?} isReplay={:?} sourceToolUseID={:?} (matches Skill tool_use = {matches_skill})", + u.uuid, u.is_meta, u.is_replay, u.source_tool_use_id); + body_sightings.push((u.uuid.clone().unwrap_or_default(), u.is_meta, u.is_replay)); + body_src_matches = body_src_matches || matches_skill; + } + } + CliMessage::Result(_) => { + results_seen += 1; + logln!("[result] turn ended (#{results_seen})"); + // The replay echo may trail the turn; stop once we've seen the body twice + // (original + replay) or a second result comes in. + if body_sightings.len() >= 2 || results_seen >= 2 { break; } + } + _ => {} + } + } + + logln!("=== CAPTURE SUMMARY ==="); + logln!("skill-body sightings = {}", body_sightings.len()); + for (i, (uuid, meta, replay)) in body_sightings.iter().enumerate() { + logln!(" [{i}] uuid={uuid} isMeta={meta:?} isReplay={replay:?}"); + } + let live_leak = body_sightings.iter().any(|(_, meta, _)| *meta != Some(true)); + logln!("LIVE-BODY-MISSING-ISMETA (leak reproduced) = {live_leak}"); + logln!("body line's sourceToolUseID matches the Skill tool_use = {body_src_matches} (→ live-safe drop signal)"); + logln!("Skill tool_use id = {skill_tool_use_id:?}"); + logln!("log written to {log_path}"); + let _ = std::fs::remove_dir_all(&tmp); + transport.shutdown().await; + } } diff --git a/src-tauri/src/supervisor/fixtures/capture_skill_live.jsonl b/src-tauri/src/supervisor/fixtures/capture_skill_live.jsonl new file mode 100644 index 0000000..fd99780 --- /dev/null +++ b/src-tauri/src/supervisor/fixtures/capture_skill_live.jsonl @@ -0,0 +1,4 @@ +{"type":"assistant","session_id":"s","uuid":"a1","message":{"id":"msg_live_1","role":"assistant","content":[{"type":"text","text":"I'll invoke the probe skill."},{"type":"tool_use","id":"toolu_live_skill","name":"Skill","input":{"skill":"probe"}}]}} +{"type":"user","session_id":"s","uuid":"u_ack","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_live_skill","content":"Launching skill: probe"}]}} +{"type":"user","session_id":"s","uuid":"u_body","message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /private/var/folders/2p/xxxx/T/tosse-skill-probe/.claude/skills/probe\n\n# Probe skill\n\nReply with exactly the word PROBE_OK and end your turn. Do nothing else.\n"}]}} +{"type":"result","subtype":"success","session_id":"s","uuid":"r1","is_error":false,"result":"PROBE_OK"} diff --git a/src-tauri/src/supervisor/protocol.rs b/src-tauri/src/supervisor/protocol.rs index 878a6a1..3cf93e0 100644 --- a/src-tauri/src/supervisor/protocol.rs +++ b/src-tauri/src/supervisor/protocol.rs @@ -258,6 +258,16 @@ pub struct UserMsg { /// is appended. Absent (→ `None`) on transcript lines. #[serde(rename = "isReplay")] pub is_replay: Option<bool>, + /// On the PERSISTED transcript, a user line injected as a tool_use's expansion (e.g. + /// the `SKILL.md` body a `Skill` tool_use expands into) carries `sourceToolUseID` = the + /// spawning tool_use's id. ⚠️ On the LIVE stdout wire the CLI OMITS this (AND `isMeta`) + /// on that line — proven by `live_capture_skill_body_replay` (both came back `None` + /// live, `isMeta:true` + `sourceToolUseID` only on disk). So it is NOT a live-safe + /// distinguisher; the live skill-body drop keys on the boilerplate prefix while a + /// `Skill` tool_use is armed (see `Assembler::skill_invocation_pending`). Kept for the + /// disk shape and future wire changes. + #[serde(rename = "sourceToolUseID")] + pub source_tool_use_id: Option<String>, } /// `result` — emitted at the end of every turn (NOT end of session; the session From 444914090bb53244971ec2e7e2c51e56f1281890 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 22:05:56 +0200 Subject: [PATCH 08/91] =?UTF-8?q?feat(shortcuts):=20nouveaux=20raccourcis,?= =?UTF-8?q?=20page=20R=C3=A9glages=20r=C3=A9cap,=20fix=20=C3=89chap/plein?= =?UTF-8?q?=20=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Registre central de bindings (ACTION_BINDINGS + matchChord) dans shortcuts.ts, source de vérité unique partagée entre le handler global et la page Réglages. - Nouveaux raccourcis globaux : ⌘B éditeur, ⌘J terminal, ⌘⇧G Git, ⌘L clean output, ⌘E extensions, ⌘N nouvelle conv, ⌘⌥↑/↓ nav conversations, ⌘⇧O historique (AZERTY-safe : lettres via e.key, flèches via e.code). - Onglet « Raccourcis » dans les Réglages : récap complet (existant + nouveau) rendu depuis le même catalogue SHORTCUT_GROUPS. - Fix « Échap sort du plein écran macOS » : garde globale en phase capture qui preventDefault Échap pour tous les cas (sauf Monaco/xterm) ; les popovers imbriqués (transcript, task output, workflow, badge tâches de fond) passent en stopPropagation pour préserver « une touche = une couche », Extensions + reply modal ne gatent plus sur defaultPrevented. - Tests : matchChord + intégrité du registre/catalogue (22 nouveaux). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/App.tsx | 112 +++++++++++++ .../conversation/TaskOutputPopover.tsx | 9 +- .../conversation/TranscriptPopover.tsx | 9 +- src/features/conversation/WorkflowDetail.tsx | 9 +- src/features/extensions/ExtensionsManager.tsx | 10 +- .../flightdeck/BackgroundTaskBadge.tsx | 10 +- .../flightdeck/FlightDeckReplyModal.tsx | 14 +- .../settings/SettingsPanel.module.css | 67 ++++++++ src/features/settings/SettingsPanel.tsx | 4 + src/features/settings/ShortcutsSection.tsx | 51 ++++++ src/store/settingsUi.ts | 8 +- src/ui/shortcuts.test.ts | 57 +++++++ src/ui/shortcuts.ts | 157 ++++++++++++++++++ 13 files changed, 489 insertions(+), 28 deletions(-) create mode 100644 src/features/settings/ShortcutsSection.tsx diff --git a/src/App.tsx b/src/App.tsx index b739e47..3941b30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,10 @@ import { FlightDeckReplyModal } from "./features/flightdeck/FlightDeckReplyModal import { useFlightdeckModal } from "./features/flightdeck/flightdeckModalStore"; import { SoundToggle } from "./features/notifications/SoundToggle"; import { ExtensionsManager } from "./features/extensions/ExtensionsManager"; +import { useExtensionsUi } from "./features/extensions/extensionsUiStore"; import { HistoryPanel } from "./features/history/HistoryPanel"; +import { useHistoryUi } from "./features/history/historyUiStore"; +import { useEditorStore } from "./features/editor/editorStore"; import { UltraCodeBlast } from "./features/conversation/UltraCodeBlast"; import { UpdateBanner } from "./features/settings/UpdateBanner"; import { AppErrorBanner } from "./ui/AppErrorBanner"; @@ -22,21 +25,27 @@ import { initNotifications } from "./notifications/notify"; import { primeAudioUnlock } from "./notifications/sound"; import { bootConversations, + createConversationInRepo, + groupConversationsByRepo, repoName, useActiveConversationId, useConversationRepo, useConversations, useConversationsStore, } from "./store/conversationsStore"; +import { useDisplay, resolveCleanOutput } from "./store/display"; import { useNotifications } from "./store/notifications"; import { useSettingsUi } from "./store/settingsUi"; import { NavBtn, Tag, Win } from "./ui/kit"; import { + ACTION_BINDINGS, isEditableTarget, isSettingsChord, isSoundToggleChord, isUndoChord, + matchChord, viewForShortcut, + type ShortcutAction, type View, } from "./ui/shortcuts"; @@ -48,6 +57,11 @@ export default function App() { const active = conversations.find((c) => c.id === activeId) ?? null; const activeRepo = useConversationRepo(activeId); const booted = useRef(false); + // Live mirror of the current view, read inside the (deps-light) keydown handler so + // conversation-scoped shortcuts (⌘B/⌘J/…) can tell whether we're on the deck without + // re-subscribing the listener on every view change. + const viewRef = useRef(view); + viewRef.current = view; // The reply modal lives ONLY on the Flight Deck. Switch views through `changeView` // so leaving the deck dismisses it SYNCHRONOUSLY (not in a post-render effect): the @@ -128,12 +142,110 @@ export default function App() { // Only consume the key if something was actually restored, so an empty undo // stack leaves any other ⌘Z handling untouched. if (useConversationsStore.getState().undoRemoveConversation()) e.preventDefault(); + return; + } + // The rest of the app-action chords (toggle panels, new/nav conversation, + // extensions, history) are driven from the shared ACTION_BINDINGS table so the + // Settings → Raccourcis page documents exactly what's wired. Conversation-scoped + // ones are inert off the conversation view; global ones fire anywhere. Like the + // chords above, ⌘+key never types a character, so they win over the editor. + for (const b of ACTION_BINDINGS) { + if (!matchChord(e, b.spec)) continue; + if (b.scope === "conversation" && viewRef.current !== "conversation") return; + if (dispatchAction(b.action)) e.preventDefault(); + return; + } + } + + /** Run one app-action shortcut; returns whether it did something (so the caller + * only swallows the key when it actually acted). Reads live store state at event + * time — no stale closures. */ + function dispatchAction(action: ShortcutAction): boolean { + const store = useConversationsStore.getState(); + const conv = store.conversations.find((c) => c.id === store.activeId) ?? null; + const editor = useEditorStore.getState(); + switch (action) { + case "toggle-editor": + if (!conv) return false; + editor.toggleOpen(); + return true; + case "toggle-terminal": + if (!conv) return false; + editor.toggleTerminal(); + return true; + case "toggle-git": + if (!conv) return false; + editor.toggleGit(); + return true; + case "toggle-clean-output": { + if (!conv) return false; + const eff = resolveCleanOutput(conv.cleanOutput ?? null, useDisplay.getState().cleanOutput); + store.setConvCleanOutput(conv.id, !eff); + return true; + } + case "open-extensions": + if (!conv) return false; + useExtensionsUi.getState().openManager({ + kind: "conversation", + path: conv.liveCwd ?? conv.cwd ?? ".", + title: conv.name, + session: conv.id, + }); + return true; + case "new-conversation": { + const repoPath = + (conv && store.repos.find((r) => r.id === conv.repoId)?.path) ?? + store.repos[0]?.path ?? + null; + if (!repoPath) return false; + createConversationInRepo(repoPath); + changeView("conversation"); + return true; + } + case "prev-conversation": + case "next-conversation": { + const ordered = groupConversationsByRepo(store.repos, store.conversations).flatMap( + (g) => g.conversations, + ); + if (ordered.length < 2) return false; + const idx = ordered.findIndex((c) => c.id === store.activeId); + if (idx < 0) return false; + const nextIdx = action === "prev-conversation" ? idx - 1 : idx + 1; + if (nextIdx < 0 || nextIdx >= ordered.length) return false; // clamp at the ends + store.selectConversation(ordered[nextIdx].id); + changeView("conversation"); + return true; + } + case "open-history": + useHistoryUi.getState().openPanel(); + return true; } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [changeView]); + // Claim Escape for the app so it never makes macOS exit NATIVE fullscreen (the OS + // default when a keydown reaches AppKit unhandled). This is the SINGLE authority for + // that: we preventDefault UNCONDITIONALLY (Monaco/xterm excepted — they own their + // Escape), in CAPTURE phase so it lands as early as possible in the dispatch. We + // never stopPropagation, so every overlay/menu still receives Escape and closes. + // + // Because this always sets `defaultPrevented`, overlays must NOT gate their close on + // it (that signal is now ours). The one-Escape-closes-one-layer ordering is instead + // enforced by the nested drill-in popovers calling `stopPropagation()` — so an outer + // window-level modal simply doesn't receive the key when an inner popover consumed it. + useEffect(() => { + function onEscape(e: KeyboardEvent) { + if (e.key !== "Escape") return; + const el = document.activeElement; + if (el && el.closest(".monaco-editor, .xterm")) return; + e.preventDefault(); + } + window.addEventListener("keydown", onEscape, true); + return () => window.removeEventListener("keydown", onEscape, true); + }, []); + return ( <Win title={view === "flightdeck" ? "Flight Deck" : active?.name ?? "Conductor"} diff --git a/src/features/conversation/TaskOutputPopover.tsx b/src/features/conversation/TaskOutputPopover.tsx index 266c8d7..8b86a61 100644 --- a/src/features/conversation/TaskOutputPopover.tsx +++ b/src/features/conversation/TaskOutputPopover.tsx @@ -125,11 +125,12 @@ export function TaskOutputPopover({ useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { - // Claim Escape (preventDefault) so an outer window-level listener — e.g. the - // Flight Deck reply modal — doesn't ALSO close on the same keypress; this - // popover is the topmost layer and owns the key while open. + // This popover is the topmost layer while open, so it OWNS Escape: stopPropagation + // keeps an outer window-level listener (e.g. the Flight Deck reply modal) from also + // closing on the same keypress. (Fullscreen is protected globally by App.tsx's + // capture-phase guard, which preventDefaults Escape.) if (e.key === "Escape") { - e.preventDefault(); + e.stopPropagation(); onClose(); } } diff --git a/src/features/conversation/TranscriptPopover.tsx b/src/features/conversation/TranscriptPopover.tsx index d09775e..e955142 100644 --- a/src/features/conversation/TranscriptPopover.tsx +++ b/src/features/conversation/TranscriptPopover.tsx @@ -88,11 +88,12 @@ export function TranscriptPopover({ useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { - // Claim Escape (preventDefault) so an outer window-level listener — e.g. the - // Flight Deck reply modal — doesn't ALSO close on the same keypress; this - // popover is the topmost layer and owns the key while open. + // This popover is the topmost layer while open, so it OWNS Escape: stopPropagation + // keeps an outer window-level listener (e.g. the Flight Deck reply modal) from also + // closing on the same keypress. (Fullscreen is protected globally by App.tsx's + // capture-phase guard, which preventDefaults Escape.) if (e.key === "Escape") { - e.preventDefault(); + e.stopPropagation(); onClose(); } } diff --git a/src/features/conversation/WorkflowDetail.tsx b/src/features/conversation/WorkflowDetail.tsx index f4e9271..2fdab7a 100644 --- a/src/features/conversation/WorkflowDetail.tsx +++ b/src/features/conversation/WorkflowDetail.tsx @@ -171,11 +171,12 @@ export function WorkflowDetail({ useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { - // Claim Escape (preventDefault) so an outer window-level listener — e.g. the - // Flight Deck reply modal — doesn't ALSO close on the same keypress; this - // popover is the topmost layer and owns the key while open. + // This popover is the topmost layer while open, so it OWNS Escape: stopPropagation + // keeps an outer window-level listener (e.g. the Flight Deck reply modal) from also + // closing on the same keypress. (Fullscreen is protected globally by App.tsx's + // capture-phase guard, which preventDefaults Escape.) if (e.key === "Escape") { - e.preventDefault(); + e.stopPropagation(); onClose(); } } diff --git a/src/features/extensions/ExtensionsManager.tsx b/src/features/extensions/ExtensionsManager.tsx index d543c48..9e6b93f 100644 --- a/src/features/extensions/ExtensionsManager.tsx +++ b/src/features/extensions/ExtensionsManager.tsx @@ -755,14 +755,12 @@ function MarketplacesPage({ const total = totalUpdates(plugins); const allOn = allMarketplacesAuto(list); - // Escape closes the overlay, like the app's other dialogs. `!e.defaultPrevented` - // keeps to the convention: if a nested layer ever consumes Escape first, we bail. + // Escape closes the overlay, like the app's other dialogs. Fullscreen is protected + // globally by the capture-phase guard in App.tsx (which always preventDefaults Escape), + // so this no longer needs to preventDefault or gate on `defaultPrevented`. useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape" && !e.defaultPrevented) { - e.preventDefault(); - onClose(); - } + if (e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); diff --git a/src/features/flightdeck/BackgroundTaskBadge.tsx b/src/features/flightdeck/BackgroundTaskBadge.tsx index ad60292..2ad2788 100644 --- a/src/features/flightdeck/BackgroundTaskBadge.tsx +++ b/src/features/flightdeck/BackgroundTaskBadge.tsx @@ -66,11 +66,17 @@ export function BackgroundTaskBadge({ convId }: { convId: string }) { // every phase as "à venir"). The hook is unconditional, so an empty task_id is safe. const openedLiveActivity = useWorkflowLive(convId, openTask?.task_id ?? ""); - // Close the popover on Escape while it's open. + // Close the popover on Escape while it's open. This popover owns Escape while open, so + // stopPropagation keeps an outer window-level modal (the Flight Deck reply modal) from + // also closing on the same keypress. Fullscreen is protected by App.tsx's capture-phase + // guard, which preventDefaults Escape globally. useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); + if (e.key === "Escape") { + e.stopPropagation(); + setOpen(false); + } } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); diff --git a/src/features/flightdeck/FlightDeckReplyModal.tsx b/src/features/flightdeck/FlightDeckReplyModal.tsx index 21b07f5..bfaeb6b 100644 --- a/src/features/flightdeck/FlightDeckReplyModal.tsx +++ b/src/features/flightdeck/FlightDeckReplyModal.tsx @@ -53,16 +53,16 @@ export function FlightDeckReplyModal({ onPromote }: { onPromote: (id: string) => // Escape closes the modal, like the app's other dialogs — but ONLY if a nested // overlay inside the mounted ConversationPane (a drill-in TranscriptPopover / - // TaskOutputPopover / WorkflowDetail) hasn't already consumed it. Those popovers - // preventDefault() on their own Escape; since keydown bubbles document→window we - // see that here and bail, so one Escape dismisses only the topmost layer. + // TaskOutputPopover / WorkflowDetail / background-task badge) hasn't already consumed + // it. Those popovers sit on `document` and call stopPropagation() on their Escape; + // since keydown bubbles document→window, this window-level listener never fires when + // an inner popover owns the key — so one Escape dismisses only the topmost layer. + // (Fullscreen is protected globally by the capture-phase guard in App.tsx, so this no + // longer needs to preventDefault or gate on defaultPrevented.) useEffect(() => { if (!convId) return; function onKey(e: KeyboardEvent) { - if (e.key === "Escape" && !e.defaultPrevented) { - e.preventDefault(); - close(); - } + if (e.key === "Escape") close(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); diff --git a/src/features/settings/SettingsPanel.module.css b/src/features/settings/SettingsPanel.module.css index a4052a3..d5a5dc8 100644 --- a/src/features/settings/SettingsPanel.module.css +++ b/src/features/settings/SettingsPanel.module.css @@ -288,3 +288,70 @@ font-size: var(--fs-sm); word-break: break-word; } + +/* ---- Raccourcis (Shortcuts) tab ------------------------------------------- */ + +.scGroup { + margin-bottom: var(--space-5); +} + +.scGroupTitle { + font-size: var(--fs-xs); + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-muted); + margin-bottom: var(--space-2); +} + +.scList { + display: flex; + flex-direction: column; +} + +.scRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); +} + +.scRow:last-child { + border-bottom: none; +} + +.scLabel { + font-size: var(--fs-base); + color: var(--text-secondary); + line-height: var(--lh-body); +} + +.scKeys { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.scSep { + color: var(--text-muted); + font-size: var(--fs-sm); + padding: 0 2px; +} + +.kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + padding: 2px 6px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: var(--fs-sm); + line-height: 1; + color: var(--text-primary); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 1px 0 var(--border); +} diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index cde6bdf..8f7f664 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -13,11 +13,13 @@ import { Toggle } from "../../ui/Toggle"; import { UpdateSection } from "./UpdateSection"; import { NotificationsSection } from "./NotificationsSection"; import { ConversationSection } from "./ConversationSection"; +import { ShortcutsSection } from "./ShortcutsSection"; import styles from "./SettingsPanel.module.css"; const TABS: Array<{ id: SettingsSection; label: string; icon: string }> = [ { id: "general", label: "Général", icon: "cog" }, { id: "conversation", label: "Conversation", icon: "chat" }, + { id: "shortcuts", label: "Raccourcis", icon: "key" }, { id: "notifications", label: "Notifications", icon: "bell" }, { id: "updates", label: "Mises à jour", icon: "refresh" }, { id: "data", label: "Données", icon: "trash" }, @@ -117,6 +119,8 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => {section === "conversation" && <ConversationSection />} + {section === "shortcuts" && <ShortcutsSection />} + {section === "notifications" && <NotificationsSection />} {section === "updates" && <UpdateSection />} diff --git a/src/features/settings/ShortcutsSection.tsx b/src/features/settings/ShortcutsSection.tsx new file mode 100644 index 0000000..4af4cbe --- /dev/null +++ b/src/features/settings/ShortcutsSection.tsx @@ -0,0 +1,51 @@ +// The "Raccourcis" settings tab: a read-only recap of every keyboard shortcut the +// app answers to. It renders the shared SHORTCUT_GROUPS catalogue (src/ui/shortcuts.ts) +// — the SAME source the global handler dispatches from for the new chords — so what's +// documented here can never drift from what's wired. +import { SHORTCUT_GROUPS } from "../../ui/shortcuts"; +import styles from "./SettingsPanel.module.css"; + +/** Split a display chord ("⌘⌥ ↑ / ⌘⌥ ↓") into the tokens we render as separate keycaps. + * We split on spaces; " / " stays as a literal separator token so alternatives read + * naturally (e.g. prev / next). */ +function keycaps(keys: string): string[] { + return keys.split(" ").filter((t) => t.length > 0); +} + +export function ShortcutsSection() { + return ( + <div> + <div className={styles.section}>Raccourcis clavier</div> + <div className={styles.desc}> + Les raccourcis disponibles dans l'application. Sur macOS, ⌘ est la touche Commande ; sous + Windows/Linux, utilisez Ctrl à la place. + </div> + + {SHORTCUT_GROUPS.map((group) => ( + <div key={group.title} className={styles.scGroup}> + <div className={styles.scGroupTitle}>{group.title}</div> + <div className={styles.scList}> + {group.items.map((item, i) => ( + <div key={`${item.keys}-${i}`} className={styles.scRow}> + <span className={styles.scLabel}>{item.label}</span> + <span className={styles.scKeys}> + {keycaps(item.keys).map((tok, j) => + tok === "/" ? ( + <span key={j} className={styles.scSep}> + / + </span> + ) : ( + <kbd key={j} className={styles.kbd}> + {tok} + </kbd> + ), + )} + </span> + </div> + ))} + </div> + </div> + ))} + </div> + ); +} diff --git a/src/store/settingsUi.ts b/src/store/settingsUi.ts index 363b73d..e1b602b 100644 --- a/src/store/settingsUi.ts +++ b/src/store/settingsUi.ts @@ -5,7 +5,13 @@ import { create } from "zustand"; /** The settings sections, mirrored by the panel's left-rail tabs. */ -export type SettingsSection = "general" | "conversation" | "notifications" | "updates" | "data"; +export type SettingsSection = + | "general" + | "conversation" + | "shortcuts" + | "notifications" + | "updates" + | "data"; interface SettingsUiState { open: boolean; diff --git a/src/ui/shortcuts.test.ts b/src/ui/shortcuts.test.ts index c1c619e..3b8e3ca 100644 --- a/src/ui/shortcuts.test.ts +++ b/src/ui/shortcuts.test.ts @@ -1,16 +1,24 @@ import { describe, it, expect } from "vitest"; import { + ACTION_BINDINGS, isEditableTarget, isSettingsChord, isSoundToggleChord, isUndoChord, + matchChord, + SHORTCUT_GROUPS, viewForShortcut, + type ChordEvent, type SettingsChordEvent, type SoundToggleChordEvent, type UndoChordEvent, type ViewShortcutEvent, } from "./shortcuts"; +function chord(p: Partial<ChordEvent>): ChordEvent { + return { key: "", code: "", metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...p }; +} + function ev(p: Partial<ViewShortcutEvent>): ViewShortcutEvent { return { code: "Digit1", metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...p }; } @@ -116,6 +124,55 @@ describe("isSettingsChord", () => { }); }); +describe("matchChord", () => { + it("matches a letter chord via the PRODUCED key (case-insensitive), ⌘ or Ctrl", () => { + expect(matchChord(chord({ metaKey: true, key: "b" }), { key: "b" })).toBe(true); + expect(matchChord(chord({ ctrlKey: true, key: "B" }), { key: "b" })).toBe(true); + expect(matchChord(chord({ metaKey: true, key: "x" }), { key: "b" })).toBe(false); + }); + + it("requires ⌘/Ctrl", () => { + expect(matchChord(chord({ key: "b" }), { key: "b" })).toBe(false); + expect(matchChord(chord({ shiftKey: true, key: "b" }), { key: "b" })).toBe(false); + }); + + it("matches Shift/Alt EXACTLY (⌘L ≠ ⌘⇧L ≠ ⌥⌘L)", () => { + // plain ⌘L requires no Shift and no Alt + expect(matchChord(chord({ metaKey: true, key: "l" }), { key: "l" })).toBe(true); + expect(matchChord(chord({ metaKey: true, shiftKey: true, key: "l" }), { key: "l" })).toBe(false); + expect(matchChord(chord({ metaKey: true, altKey: true, key: "l" }), { key: "l" })).toBe(false); + // a shift-required chord (⌘⇧G) only fires WITH shift + expect(matchChord(chord({ metaKey: true, shiftKey: true, key: "g" }), { key: "g", shift: true })).toBe(true); + expect(matchChord(chord({ metaKey: true, key: "g" }), { key: "g", shift: true })).toBe(false); + }); + + it("matches an arrow chord via the PHYSICAL code (⌘⌥↑ / ⌘⌥↓)", () => { + expect(matchChord(chord({ metaKey: true, altKey: true, code: "ArrowUp" }), { code: "ArrowUp", alt: true })).toBe(true); + expect(matchChord(chord({ metaKey: true, altKey: true, code: "ArrowDown" }), { code: "ArrowUp", alt: true })).toBe(false); + // the arrow chords require Alt: bare ⌘↑ must not match + expect(matchChord(chord({ metaKey: true, code: "ArrowUp" }), { code: "ArrowUp", alt: true })).toBe(false); + }); +}); + +describe("ACTION_BINDINGS / SHORTCUT_GROUPS", () => { + it("has a unique chord per action (no two bindings collide)", () => { + const seen = new Set<string>(); + for (const b of ACTION_BINDINGS) { + const sig = `${b.spec.key ?? ""}|${b.spec.code ?? ""}|${b.spec.shift ? "S" : ""}|${b.spec.alt ? "A" : ""}`; + expect(seen.has(sig), `duplicate chord for ${b.action}`).toBe(false); + seen.add(sig); + } + }); + + it("every catalogue group is non-empty (the Settings recap renders something)", () => { + expect(SHORTCUT_GROUPS.length).toBeGreaterThan(0); + for (const g of SHORTCUT_GROUPS) { + expect(g.title.length).toBeGreaterThan(0); + expect(g.items.length).toBeGreaterThan(0); + } + }); +}); + describe("isEditableTarget", () => { it("treats text inputs, textareas and selects as editable", () => { expect(isEditableTarget(document.createElement("input"))).toBe(true); diff --git a/src/ui/shortcuts.ts b/src/ui/shortcuts.ts index fbaf864..3f1aab7 100644 --- a/src/ui/shortcuts.ts +++ b/src/ui/shortcuts.ts @@ -126,3 +126,160 @@ export function isEditableTarget(el: Element | null): boolean { ) != null ); } + +// ---- Generic ⌘/Ctrl chord table (the app-action shortcuts) ------------------ +// On top of the four historical chords above (view switch / undo / sound / settings) +// we drive a small TABLE of extra bindings from one place: the global App handler +// matches an event against each spec and runs the mapped action, and the +// Settings → Raccourcis page renders the same catalogue — one source of truth so a +// wired shortcut and its documentation can never drift. + +/** The full keyboard-event shape the generic matcher inspects (a DOM + * `KeyboardEvent` satisfies it structurally). */ +export interface ChordEvent { + key: string; + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +/** + * How to match one chord. ⌘/Ctrl is ALWAYS required. Match on `code` (the PHYSICAL + * key — for digits and arrows) or on `key` (the PRODUCED character — for letters): + * the same AZERTY reasoning as the dedicated helpers above (letters are layout-stable + * under `key`, whereas a letter's `code` names its QWERTY position). `shift`/`alt` + * default to false and must match EXACTLY, so ⌘L never fires on ⌘⇧L or ⌥⌘L. + */ +export interface ChordSpec { + key?: string; + code?: string; + shift?: boolean; + alt?: boolean; +} + +/** Whether `e` matches `spec` — ⌘/Ctrl required, Shift/Alt matched exactly, and the + * key compared via `code` (physical) or `key` (produced char, case-insensitive). */ +export function matchChord(e: ChordEvent, spec: ChordSpec): boolean { + if (!(e.metaKey || e.ctrlKey)) return false; + if (e.altKey !== (spec.alt ?? false)) return false; + if (e.shiftKey !== (spec.shift ?? false)) return false; + if (spec.code) return e.code === spec.code; + if (spec.key) return e.key.toLowerCase() === spec.key.toLowerCase(); + return false; +} + +/** The extra actions dispatched by the global handler (beyond the historical + * view-switch / undo / sound / settings chords). */ +export type ShortcutAction = + | "toggle-editor" + | "toggle-terminal" + | "toggle-git" + | "toggle-clean-output" + | "open-extensions" + | "new-conversation" + | "prev-conversation" + | "next-conversation" + | "open-history"; + +/** `global` fires anywhere; `conversation` only in the conversation view with an + * active conversation (so ⌘B/⌘J/… are inert on the Flight Deck). */ +export type BindingScope = "global" | "conversation"; + +export interface ActionBinding { + action: ShortcutAction; + spec: ChordSpec; + scope: BindingScope; +} + +/** + * The chords added on top of the historical ones. Letters use `key` (AZERTY-robust); + * the conversation-navigation arrows use `code` (position-stable). These are GLOBAL + * (they never type a character under ⌘, so they win over the editor's same chord — + * the VS Code convention). The App handler matches each `spec`; Settings → Raccourcis + * documents them via {@link SHORTCUT_GROUPS}. + */ +export const ACTION_BINDINGS: ActionBinding[] = [ + { action: "toggle-editor", spec: { key: "b" }, scope: "conversation" }, + { action: "toggle-terminal", spec: { key: "j" }, scope: "conversation" }, + { action: "toggle-git", spec: { key: "g", shift: true }, scope: "conversation" }, + { action: "toggle-clean-output", spec: { key: "l" }, scope: "conversation" }, + { action: "open-extensions", spec: { key: "e" }, scope: "conversation" }, + { action: "new-conversation", spec: { key: "n" }, scope: "global" }, + { action: "prev-conversation", spec: { code: "ArrowUp", alt: true }, scope: "global" }, + { action: "next-conversation", spec: { code: "ArrowDown", alt: true }, scope: "global" }, + { action: "open-history", spec: { key: "o", shift: true }, scope: "global" }, +]; + +// ---- Display catalogue (Settings → Raccourcis) ------------------------------ + +export interface ShortcutDoc { + /** Human-readable chord, e.g. "⌘ L" or "⌘⌥ ↑ / ⌘⌥ ↓". */ + keys: string; + label: string; +} + +export interface ShortcutGroup { + title: string; + items: ShortcutDoc[]; +} + +/** Every shortcut the app answers to, grouped by scope, for the Settings recap page. + * Kept alongside the dispatch tables above so documentation and behaviour live in one + * file. (The historical chords are listed here by hand; the new ones mirror + * {@link ACTION_BINDINGS}.) */ +export const SHORTCUT_GROUPS: ShortcutGroup[] = [ + { + title: "Global", + items: [ + { keys: "⌘ 1", label: "Vue Conversation" }, + { keys: "⌘ 2", label: "Vue Flight Deck" }, + { keys: "⌘ N", label: "Nouvelle conversation" }, + { keys: "⌘⌥ ↑ / ⌘⌥ ↓", label: "Conversation précédente / suivante" }, + { keys: "⌘⇧ O", label: "Ouvrir l'historique des conversations" }, + { keys: "⌘⇧ M", label: "Couper / rétablir le son des notifications" }, + { keys: "⌘ ,", label: "Ouvrir les Réglages" }, + { keys: "⌘ Z", label: "Restaurer la dernière conversation supprimée" }, + ], + }, + { + title: "Vue Conversation", + items: [ + { keys: "⌘ B", label: "Ouvrir / fermer l'éditeur de fichiers" }, + { keys: "⌘ J", label: "Ouvrir / fermer le terminal intégré" }, + { keys: "⌘⇧ G", label: "Ouvrir / fermer le panneau Git" }, + { keys: "⌘ L", label: "Basculer le « clean output » de la conversation" }, + { keys: "⌘ E", label: "Ouvrir les Extensions (MCP, plugins, skills, sous-agents)" }, + ], + }, + { + title: "Compositeur", + items: [ + { keys: "↵", label: "Envoyer le message" }, + { keys: "⇧ ↵", label: "Retour à la ligne" }, + { keys: "⇧ ⇥", label: "Changer le mode de permission" }, + { keys: "↑ / ↓", label: "Rappeler le message précédent / suivant (en bord de texte)" }, + { keys: "/", label: "Menu des commandes — ↑↓ naviguer, ↵/⇥ choisir, Échap fermer" }, + ], + }, + { + title: "Éditeur", + items: [ + { keys: "⌘ S", label: "Enregistrer le fichier" }, + { keys: "⌘ W", label: "Fermer l'onglet actif" }, + { keys: "↵ / Échap", label: "Renommer : valider / annuler (arborescence et sidebar)" }, + ], + }, + { + title: "Git & Revue", + items: [ + { keys: "⌘ ↵", label: "Valider le commit (dans la zone de message)" }, + { keys: "⌘ ↵", label: "Marquer « Vu » / envoyer une note de plan" }, + ], + }, + { + title: "Fenêtres & popovers", + items: [{ keys: "Échap", label: "Fermer la fenêtre, le popover ou le menu actif" }], + }, +]; From 0c43c85ee7568ef00f0330c0ea84a6e4954c713e Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 22:09:55 +0200 Subject: [PATCH 09/91] =?UTF-8?q?feat(lifecycle):=20garde-fous=20suppressi?= =?UTF-8?q?on=20conv=20en=20cours=20+=20refonte=20page=20mise=20=C3=A0=20j?= =?UTF-8?q?our?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suppression : confirmation avant de supprimer une conversation ACTIVE (tour en vol ou tâches de fond) ; friction-free (1 clic, ⌘Z) préservé pour les convs inactives. - Page MAJ : notes rendues en Markdown, blabla Gatekeeper masqué in-app via le marqueur `<!-- gh-only -->`, hero version, pop-up de redémarrage TOUJOURS affichée (renforcée si des sessions tournent). - Pipeline notes : CHANGELOG.md lu par release.yml, note d'install déplacée dans .github/release-install-note.md, skill /release étape 3b ; inAppReleaseNotes + test. - Correctifs revue adversariale : garde sur le compteur brut de tâches de fond (kind review/error masquait backgrounding) ; Échap ne ferme qu'une couche (ConfirmDialog preventDefault + SettingsPanel !defaultPrevented) ; :global(.wf-ico) ; auto-close du confirm stale ; props CSS mortes retirées. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .claude/skills/release/SKILL.md | 28 +++++- .github/release-install-note.md | 7 ++ .github/workflows/release.yml | 44 +++++++-- CHANGELOG.md | 16 ++++ src/agent/status.ts | 12 +++ .../conversation/ConductorSidebar.tsx | 56 ++++++++++- .../settings/SettingsPanel.module.css | 96 ++++++++++++++++--- src/features/settings/SettingsPanel.tsx | 6 +- src/features/settings/UpdateSection.tsx | 91 ++++++++++++++++-- src/store/updater.test.ts | 41 ++++++++ src/store/updater.ts | 25 +++++ src/ui/ConfirmDialog.tsx | 9 +- 12 files changed, 392 insertions(+), 39 deletions(-) create mode 100644 .github/release-install-note.md create mode 100644 CHANGELOG.md create mode 100644 src/store/updater.test.ts diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index c74bb93..0a1d498 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -37,6 +37,32 @@ pnpm bump <patch|minor|major|X.Y.Z> Le script met à jour les 4 emplacements d'un coup (`tauri.conf.json`, `package.json`, `Cargo.toml`, `Cargo.lock`). **N'édite jamais ces versions à la main.** +## Étape 3b — Mettre à jour `CHANGELOG.md` (les nouveautés affichées DANS l'app) + +`CHANGELOG.md` (racine) alimente la page « Mise à jour » de l'app : `release.yml` lit la section de la version qu'on release et la met en description de la release GitHub, que l'updater affiche. Il faut donc une section pour la **nouvelle** version. **Reste simple et rapide** — quelques puces suffisent, ne sur-analyse pas. + +1. Liste les commits depuis la dernière release : + + ```bash + LAST=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "") + git log ${LAST:+$LAST..}HEAD --no-merges --pretty=format:'%s' + ``` + +2. Résume-les en **3 à 6 puces courtes, orientées utilisateur** (ce que ça change POUR LUI, pas le détail technique). Garde les `feat:` / `fix:` parlants ; **ignore** `chore:`, `docs:`, `test:`, `refactor:`, `chore(release):` et les merges. Reformule en français clair. + +3. Ajoute la section **tout en haut** du corps de `CHANGELOG.md` (juste sous le bloc d'en-tête, avant la section précédente), au format **exact** : + + ```md + ## vX.Y.Z + + - Première nouveauté + - Deuxième nouveauté + ``` + + où `X.Y.Z` = la version que tu viens de bumper (étape 3). ⚠️ Le `v` du titre est **obligatoire** (`release.yml` cherche `## v<version>`). Si une section `## vX.Y.Z` de cette version existe **déjà** (préparée pendant le dev), garde-la ou affine-la — n'en crée pas une deuxième. + +Si tu n'es pas sûr ou qu'il n'y a rien de notable, une seule puce générique suffit : le workflow a de toute façon un repli si la section manque. Ne bloque jamais la release pour le changelog. + ## Étape 4 — Synchroniser `CLAUDE.md` avec TOSSE Avant de pousser, régénère `CLAUDE.md` depuis les contextes TOSSE pour que la release parte avec la doc projet à jour. @@ -47,7 +73,7 @@ Le fichier régénéré sera embarqué par le `git add -A` de l'étape suivante. ## Étape 5 — Commiter et pousser `dev` -Le `git add -A` inclut à la fois le bump de version et le `CLAUDE.md` régénéré. +Le `git add -A` inclut le bump de version, le `CHANGELOG.md` mis à jour (étape 3b) et le `CLAUDE.md` régénéré. ```bash git add -A diff --git a/.github/release-install-note.md b/.github/release-install-note.md new file mode 100644 index 0000000..d3aaf1f --- /dev/null +++ b/.github/release-install-note.md @@ -0,0 +1,7 @@ +⚠️ App signée (certificat auto-signé) mais **non notarisée** : au premier lancement, +faire **clic droit → Ouvrir** (ou `xattr -cr /Applications/Tosse\ Code.app`) pour +passer Gatekeeper. + +ℹ️ Sur cette mise à jour, macOS peut redemander **une dernière fois** l'accès à vos +dossiers (Bureau/Documents/…) — c'est la transition vers la signature stable ; +ensuite les autorisations persistent d'une version à l'autre. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecf3580..29fc934 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,37 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Version à publier : $VERSION" + # Corps de la release = les nouveautés du CHANGELOG.md (affichées DANS l'app via + # l'updater) + la note d'installation Gatekeeper APRÈS un marqueur `<!-- gh-only -->` + # (visible seulement sur la page GitHub — l'app la masque, cf. `inAppReleaseNotes`). + # Le CHANGELOG est rempli au moment du bump par le skill `/release`. + - name: Composer les notes de release + id: notes + run: | + set -euo pipefail + VERSION="${{ steps.version.outputs.version }}" + # Section « ## vX.Y.Z » du CHANGELOG, jusqu'au prochain titre « ## ». + SECTION="$(awk -v ver="$VERSION" ' + /^## / { if (cap) exit; if (index($0, "v" ver) > 0) { cap=1; next } } + cap { print } + ' CHANGELOG.md 2>/dev/null || true)" + # Vide (section absente / CHANGELOG manquant) → repli générique : on ne bloque + # JAMAIS une release pour des notes manquantes. + if [ -z "$(printf '%s' "$SECTION" | tr -d '[:space:]')" ]; then + SECTION="- Améliorations et corrections diverses." + fi + # La note d'installation Gatekeeper vit dans un fichier (pas de heredoc à la + # colonne 0 qui casserait le bloc YAML) : on l'ajoute après le marqueur. + { + echo "body<<RELEASE_BODY_EOF" + printf '%s\n\n' "$SECTION" + echo '<!-- gh-only -->' + echo "" + cat .github/release-install-note.md + echo "RELEASE_BODY_EOF" + } >> "$GITHUB_OUTPUT" + echo "Notes de release composées pour v$VERSION." + - name: Refuser une version déjà publiée env: GH_TOKEN: ${{ github.token }} @@ -181,16 +212,9 @@ jobs: with: tagName: v${{ steps.version.outputs.version }} releaseName: "Tosse Code v${{ steps.version.outputs.version }}" - releaseBody: | - Build automatique depuis `main` — macOS universel (Apple Silicon + Intel). - - ⚠️ App signée (certificat auto-signé) mais **non notarisée** : au premier lancement, - faire **clic droit → Ouvrir** (ou `xattr -cr /Applications/Tosse\ Code.app`) pour - passer Gatekeeper. - - ℹ️ Sur cette mise à jour, macOS peut redemander **une dernière fois** l'accès à vos - dossiers (Bureau/Documents/…) — c'est la transition vers la signature stable ; - ensuite les autorisations persistent d'une version à l'autre. + # Composé à l'étape « Composer les notes de release » : nouveautés (CHANGELOG, + # affichées in-app) + marqueur `<!-- gh-only -->` + note d'installation (GitHub only). + releaseBody: ${{ steps.notes.outputs.body }} # Brouillon d'abord : la signature est VÉRIFIÉE (étape suivante) AVANT publication, # pour qu'une release mal signée reste un brouillon plutôt que d'être publiée. releaseDraft: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5ecca81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +Nouveautés de chaque version, **affichées dans l'app** au moment de la mise à jour. + +Convention : une section `## vX.Y.Z` par version (la plus récente en haut), avec des +puces courtes orientées **utilisateur** — pas de détails techniques internes. Le skill +`/release` ajoute automatiquement la section de la nouvelle version à partir des +commits ; `release.yml` lit cette section et la met en description de la release +GitHub, que l'app affiche telle quelle. Le bloc d'instructions d'installation (après +le marqueur `<!-- gh-only -->`) est ajouté par `release.yml` et reste **seulement** +sur la page GitHub — il n'apparaît pas dans l'app. + +## v0.24.0 + +- Confirmation avant de supprimer une conversation **en cours d'exécution** (les conversations inactives se suppriment toujours en un clic, annulable avec ⌘Z). +- Refonte de la page de mise à jour : nouveautés de version lisibles, et avertissement clair avant le redémarrage — avec le nombre de conversations en cours qui seront interrompues. diff --git a/src/agent/status.ts b/src/agent/status.ts index 52b268e..6e83ab6 100644 --- a/src/agent/status.ts +++ b/src/agent/status.ts @@ -222,6 +222,18 @@ export function rowAttention(s: AgentStatus): "input" | "review" | "error" | nul } } +/** + * Whether the conversation is actively doing work right now — a turn in flight + * (`running`) or background tools still running (`backgrounding`). Deleting it in + * these states kills live work, so the sidebar gates the otherwise friction-free, + * ⌘Z-undoable delete behind a confirm here. The blocked states + * (needInput / needIntervention) are paused waiting on the user — not running — and + * a settled review/error/idle/off is inert; those keep the one-click delete. + */ +export function isActivelyRunning(s: AgentStatus): boolean { + return s.kind === "running" || s.kind === "backgrounding"; +} + /** * Sort key for the FlightDeck: what to surface first. Lower = more important, so * action-required and errors come first (leftmost in a lane / top repo), then diff --git a/src/features/conversation/ConductorSidebar.tsx b/src/features/conversation/ConductorSidebar.tsx index 577c8e2..7a5da58 100644 --- a/src/features/conversation/ConductorSidebar.tsx +++ b/src/features/conversation/ConductorSidebar.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { pickFolder } from "../../ipc/pickFolder"; import { Splitter } from "../editor/Splitter"; import { useSidebar } from "../../store/sidebar"; @@ -13,7 +13,14 @@ import { type Conversation, } from "../../store/conversationsStore"; import { useAgentStatus } from "../../agent/useAgentStatus"; -import { agentStatusToDot, isDismissable, rowAttention, type AgentStatus } from "../../agent/status"; +import { useRunningTaskCount } from "../../store/backgroundTasksStore"; +import { + agentStatusToDot, + isActivelyRunning, + isDismissable, + rowAttention, + type AgentStatus, +} from "../../agent/status"; import { useSettingsUi } from "../../store/settingsUi"; import { useSidebarFold, useRepoCollapsed } from "../../store/sidebarFold"; import { SettingsPanel } from "../settings/SettingsPanel"; @@ -36,17 +43,36 @@ function ConvRow({ conv, active }: { conv: Conversation; active: boolean }) { // live events back to it). Drives the dot colour, the whole-row highlight, and // the "Vu" acknowledge button. const status = useAgentStatus(conv.id); + // Raw count of running background tools for this conversation. We gate the delete + // confirm on this DIRECTLY (not just `isActivelyRunning(status)`), because a running + // background task can be masked by a higher-priority derived kind: when the last + // turn is unseen, deriveAgentStatus reports `review`/`error`/`needInput` even while + // background work is live (see status.ts + status.test.ts). Reading the count avoids + // the friction-free × silently killing that live work. + const runningBgTasks = useRunningTaskCount(conv.id); const attn = rowAttention(status); const select = useConversationsStore((s) => s.selectConversation); const rename = useConversationsStore((s) => s.renameConversation); const remove = useConversationsStore((s) => s.removeConversation); const [editing, setEditing] = useState(false); + // Only when the conversation is actively running (turn in flight / background + // tools) does the friction-free × ask first — deleting then kills live work. + const [confirmingDelete, setConfirmingDelete] = useState(false); const [draft, setDraft] = useState(conv.name); // Both Enter/blur commit and Escape cancel unmount the input, which fires a // trailing `onBlur`. This latch makes the commit run exactly once and lets // Escape suppress it entirely (so a cancel never writes the edited draft). const settled = useRef(false); + // "Busy" for delete-safety: a turn in flight OR any background work still running. + const busyForDelete = isActivelyRunning(status) || runningBgTasks > 0; + // If the work settles while the confirm is open, its "still running / work may be + // lost" copy is stale and no confirm is warranted anymore — close it (the × goes + // back to friction-free). Guarded on `confirmingDelete` so it's a no-op otherwise. + useEffect(() => { + if (confirmingDelete && !busyForDelete) setConfirmingDelete(false); + }, [confirmingDelete, busyForDelete]); + function startEdit() { settled.current = false; setDraft(conv.name); @@ -121,7 +147,11 @@ function ConvRow({ conv, active }: { conv: Conversation; active: boolean }) { confirm dialog. It's reversible with ⌘Z (undoRemoveConversation) — the on-disk transcript is never touched, so nothing is truly lost. Mirrors the "Vu" button (cv-sess-seen) — revealed on row hover, danger-tinted. The × is the row's only - inline affordance: rename is double-click on the name (startEdit). */} + inline affordance: rename is double-click on the name (startEdit). + EXCEPTION: while the conversation is actively running (turn in flight or + background tools), the click opens a confirm first — deleting then stops the + live session and can drop unfinished work (⌘Z restores the conversation but + not the killed run). Idle/settled conversations keep the one-click delete. */} <button type="button" className="cv-sess-del" @@ -129,11 +159,29 @@ function ConvRow({ conv, active }: { conv: Conversation; active: boolean }) { aria-label="Supprimer la conversation" onClick={(e) => { e.stopPropagation(); - remove(conv.id); + if (busyForDelete) setConfirmingDelete(true); + else remove(conv.id); }} > <Ico name="x" className="sm" /> </button> + {confirmingDelete ? ( + <ConfirmDialog + open + danger + title={`Supprimer « ${conv.name} » ?`} + confirmLabel="Supprimer quand même" + onCancel={() => setConfirmingDelete(false)} + onConfirm={() => { + setConfirmingDelete(false); + remove(conv.id); + }} + > + Cette conversation est <strong>en cours d'exécution</strong>. La supprimer va{" "} + <strong>arrêter la session Claude</strong> et le travail non terminé peut être + perdu. La conversation reste récupérable avec ⌘Z, mais pas le run interrompu. + </ConfirmDialog> + ) : null} </div> ); } diff --git a/src/features/settings/SettingsPanel.module.css b/src/features/settings/SettingsPanel.module.css index a4052a3..c27fd69 100644 --- a/src/features/settings/SettingsPanel.module.css +++ b/src/features/settings/SettingsPanel.module.css @@ -233,19 +233,93 @@ /* ---- Update section ----------------------------------------------------- */ -.notes { - margin: 0 0 var(--space-4); - padding: var(--space-3); - max-height: 160px; - overflow: auto; - background: var(--bg-hover); +/* Inviting header for an available update: a soft accent card with the update + motif and a clear current → new version jump. */ +.updateHero { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 14px 16px; + margin-bottom: var(--space-4); + background: var(--accent-dim, var(--bg-hover)); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); +} +.updateSpark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + flex: none; + border-radius: 50%; + background: var(--accent); + color: var(--accent-text); +} +/* Ico renders the GLOBAL `wf-ico` class → must escape CSS-module hashing with + :global, otherwise this descendant rule never matches (icon stays 15px). */ +.updateSpark :global(.wf-ico) { + width: 18px; + height: 18px; +} +.updateHeroText { + min-width: 0; +} +.updateHeadline { + font-size: var(--fs-base); + font-weight: 600; + color: var(--text-primary); +} +.versionJump { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: 2px; + font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; +} +.versionJump :global(.wf-ico) { + color: var(--text-muted); +} +.versionOld { + color: var(--text-muted); +} +.versionNew { + color: var(--accent); + font-weight: 600; +} + +/* Release notes ("Nouveautés"), rendered as Markdown inside a framed card. */ +.notesCard { + margin-bottom: var(--space-4); border: 1px solid var(--wf-line); border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: var(--fs-sm); - line-height: var(--lh-body); - white-space: pre-wrap; - word-break: break-word; + overflow: hidden; +} +.notesTitle { + padding: 8px 12px; + background: var(--bg-hover); + border-bottom: 1px solid var(--wf-line); + font-size: var(--fs-xs); + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-muted); +} +.notesBody { + padding: 2px 14px; + max-height: 240px; + overflow: auto; + /* The Markdown container (StreamMarkdown `.root`) sets its own color / font-size / + line-height, so typographic props here would be inert — kept box-only on purpose. */ +} + +/* The update CTA: a touch bigger, icon + label, to invite the click. */ +.btnUpdate { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: 34px; + font-weight: 600; } .progressWrap { diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index cde6bdf..6f2fef6 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -40,11 +40,13 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => .catch(() => setVersion(null)); }, [open]); - // Close on Escape, but never mid-wipe. + // Close on Escape, but never mid-wipe, and never when a higher layer already + // consumed it (a ConfirmDialog mounted inside — e.g. the update relaunch confirm — + // calls preventDefault so its Escape doesn't ALSO tear down the whole panel). useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) close(); + if (e.key === "Escape" && !busy && !e.defaultPrevented) close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); diff --git a/src/features/settings/UpdateSection.tsx b/src/features/settings/UpdateSection.tsx index 26c5de9..ea53720 100644 --- a/src/features/settings/UpdateSection.tsx +++ b/src/features/settings/UpdateSection.tsx @@ -1,7 +1,15 @@ // "Mise à jour" section of the Settings panel: current vs available version, -// release notes, a manual check button, download progress, and the one-click -// "install + restart" action. All state lives in the updater store. -import { useUpdater } from "../../store/updater"; +// release notes (rendered as Markdown), a manual check button, download progress, +// and the "install + restart" action — gated behind a relaunch confirmation so a +// running conversation is never killed by surprise. All state lives in the updater +// store; the in-app notes are the changelog part of the release body (the GitHub-only +// install instructions are stripped, see `inAppReleaseNotes`). +import { useState } from "react"; +import { useUpdater, inAppReleaseNotes } from "../../store/updater"; +import { useConversationsStore } from "../../store/conversationsStore"; +import { StreamMarkdown } from "../conversation/StreamMarkdown"; +import { ConfirmDialog } from "../../ui/ConfirmDialog"; +import { Ico } from "../../ui/kit"; import styles from "./SettingsPanel.module.css"; function formatBytes(n: number): string { @@ -18,10 +26,18 @@ export function UpdateSection() { const lastCheckError = useUpdater((s) => s.lastCheckError); const check = useUpdater((s) => s.check); const install = useUpdater((s) => s.install); + // How many Claude sessions are live (a running `claude` process). Relaunching to + // install interrupts every one of them — the confirm dialog warns about this. + const liveSessions = useConversationsStore( + (s) => s.conversations.filter((c) => c.handle !== null).length, + ); + + const [confirmingInstall, setConfirmingInstall] = useState(false); const checking = status === "checking"; const downloading = status === "downloading" || status === "installing"; const hasUpdate = !!update && (status === "available" || downloading); + const notes = inAppReleaseNotes(update?.notes); const pct = progress && progress.total @@ -34,11 +50,31 @@ export function UpdateSection() { {hasUpdate && update ? ( <> - <div className={styles.desc}> - <strong>Version {update.version} disponible</strong> — actuelle :{" "} - {update.currentVersion}. + {/* Inviting header: the update motif + a clear current → new version jump. */} + <div className={styles.updateHero}> + <span className={styles.updateSpark}> + <Ico name="spark" /> + </span> + <div className={styles.updateHeroText}> + <div className={styles.updateHeadline}>Nouvelle version disponible</div> + <div className={styles.versionJump}> + <span className={styles.versionOld}>v{update.currentVersion}</span> + <Ico name="arrow" className="sm" /> + <span className={styles.versionNew}>v{update.version}</span> + </div> + </div> </div> - {update.notes ? <pre className={styles.notes}>{update.notes.trim()}</pre> : null} + + {notes ? ( + <div className={styles.notesCard}> + <div className={styles.notesTitle}>Nouveautés</div> + <div className={styles.notesBody}> + <StreamMarkdown text={notes} /> + </div> + </div> + ) : ( + <div className={styles.desc}>Améliorations et corrections diverses.</div> + )} {downloading && progress ? ( <div className={styles.progressWrap}> @@ -62,17 +98,18 @@ export function UpdateSection() { <div className={styles.row}> <button - className={`${styles.btn} ${styles.primary}`} - onClick={() => void install()} + className={`${styles.btn} ${styles.primary} ${styles.btnUpdate}`} + onClick={() => setConfirmingInstall(true)} disabled={downloading} > + <Ico name="refresh" className="sm" /> {status === "installing" ? "Redémarrage…" : status === "downloading" ? "Téléchargement…" : error ? "Réessayer l'installation" - : "Installer et redémarrer"} + : "Mettre à jour et redémarrer"} </button> </div> </> @@ -103,6 +140,40 @@ export function UpdateSection() { )} {error ? <div className={styles.errorMsg}>{error}</div> : null} + + {/* Relaunch confirmation — ALWAYS shown before installing, because installing + relaunches the app and drops every live session. Reinforced (danger + count + + "Attendre") when conversations are actually running. */} + <ConfirmDialog + open={confirmingInstall} + danger={liveSessions > 0} + title="Mettre à jour Tosse Code ?" + confirmLabel={liveSessions > 0 ? "Mettre à jour maintenant" : "Mettre à jour et redémarrer"} + cancelLabel={liveSessions > 0 ? "Attendre" : "Annuler"} + onCancel={() => setConfirmingInstall(false)} + onConfirm={() => { + setConfirmingInstall(false); + void install(); + }} + > + L'application va <strong>redémarrer</strong> pour installer + {update ? ` la version ${update.version}` : " la mise à jour"}. + {liveSessions > 0 ? ( + <> + {" "} + <strong> + {liveSessions} conversation{liveSessions > 1 ? "s" : ""} en cours + </strong>{" "} + {liveSessions > 1 ? "seront interrompues" : "sera interrompue"} — le travail non + terminé peut être perdu. Vous pouvez <strong>attendre</strong> qu'{liveSessions > 1 + ? "elles se terminent" + : "elle se termine"}{" "} + avant de mettre à jour. + </> + ) : ( + " Aucune conversation n'est en cours." + )} + </ConfirmDialog> </div> ); } diff --git a/src/store/updater.test.ts b/src/store/updater.test.ts new file mode 100644 index 0000000..3d1a070 --- /dev/null +++ b/src/store/updater.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { inAppReleaseNotes, GH_ONLY_MARKER } from "./updater"; + +describe("inAppReleaseNotes", () => { + it("returns null for empty / missing input", () => { + expect(inAppReleaseNotes(null)).toBeNull(); + expect(inAppReleaseNotes(undefined)).toBeNull(); + expect(inAppReleaseNotes(" \n ")).toBeNull(); + }); + + it("keeps only the part before the gh-only marker", () => { + const body = [ + "## Nouveautés", + "- Confirmation avant suppression d'une conversation en cours", + "- Refonte de la page de mise à jour", + "", + GH_ONLY_MARKER, + "⚠️ App signée mais non notarisée : clic droit → Ouvrir.", + ].join("\n"); + const notes = inAppReleaseNotes(body); + expect(notes).toContain("Nouveautés"); + expect(notes).toContain("Refonte de la page"); + expect(notes).not.toContain("notarisée"); + expect(notes).not.toContain(GH_ONLY_MARKER); + }); + + it("drops a legacy install-only body (no marker)", () => { + const legacy = [ + "Build automatique depuis `main` — macOS universel.", + "", + "⚠️ App signée (certificat auto-signé) mais **non notarisée** : au premier lancement,", + "faire **clic droit → Ouvrir**.", + ].join("\n"); + expect(inAppReleaseNotes(legacy)).toBeNull(); + }); + + it("passes through a plain changelog with no marker", () => { + const body = "## Nouveautés\n- Correction de bugs"; + expect(inAppReleaseNotes(body)).toBe(body); + }); +}); diff --git a/src/store/updater.ts b/src/store/updater.ts index c71b234..ba0ad1a 100644 --- a/src/store/updater.ts +++ b/src/store/updater.ts @@ -25,6 +25,31 @@ export interface AvailableUpdate { notes?: string; } +/** Separator in a release body: everything AFTER it is GitHub-page-only (the + * self-signed / Gatekeeper install instructions, useful for a manual .dmg download + * but pointless for the in-app auto-updater). See `.github/workflows/release.yml`. */ +export const GH_ONLY_MARKER = "<!-- gh-only -->"; + +// Legacy releases (before the CHANGELOG-driven body) shipped ONLY the Gatekeeper / +// install boilerplate as their body, with no marker. Recognise it so those notes +// don't leak into the app as if they were the "what's new". +const LEGACY_INSTALL_BOILERPLATE = /non notaris|clic droit\s*→\s*ouvrir|build automatique depuis/i; + +/** + * The user-facing "what's new" to show IN-APP, extracted from a raw release body. + * Keeps only the part before {@link GH_ONLY_MARKER} (the changelog); drops the + * GitHub-only install note. Returns null when there is nothing meaningful to show — + * empty, or a legacy install-only body — so the UI can fall back to a neutral line. + */ +export function inAppReleaseNotes(notes: string | null | undefined): string | null { + if (!notes) return null; + const idx = notes.indexOf(GH_ONLY_MARKER); + const text = (idx >= 0 ? notes.slice(0, idx) : notes).trim(); + // No marker + looks like the legacy install-only body → nothing worth surfacing. + if (idx < 0 && LEGACY_INSTALL_BOILERPLATE.test(text)) return null; + return text.length > 0 ? text : null; +} + interface UpdaterState { status: UpdaterStatus; update: AvailableUpdate | null; diff --git a/src/ui/ConfirmDialog.tsx b/src/ui/ConfirmDialog.tsx index d12e7cb..768c8cf 100644 --- a/src/ui/ConfirmDialog.tsx +++ b/src/ui/ConfirmDialog.tsx @@ -39,7 +39,14 @@ export function ConfirmDialog({ useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onCancel(); + // Own Escape while open: preventDefault so an outer layer whose own Escape + // handler checks `!e.defaultPrevented` (e.g. the Settings panel closing on + // Escape) doesn't ALSO fire — one Escape closes exactly one layer (the repo's + // "topmost layer claims Escape" convention). + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); From f41d601e0bae43e018e8c7b5456fbb9994268817 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 22:14:40 +0200 Subject: [PATCH 10/91] feat(flightdeck): cartes cliquables + pop-ups (conversation, dernier message, to-do) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clic sur le corps d'une carte → ouvre la conversation en modale (le titre reste l'entrée plein écran) ; bouton « Ouvrir » retiré - résumé du dernier message → pop-up avec le message complet - pips de to-do → pop-up avec la to-do list détaillée (TodoList réutilisé) - CardPopover porté (document.body) + collision-detection : jamais tronqué par un bord d'écran (flip haut/bas + clamp horizontal) - garde clic-corps contre le bubbling des events de portal (contains) Revue adversariale (7 dims × 3 lentilles) : 2 fixes CSS LOW confirmés appliqués (hover n'écrase plus l'accent .att/.rev ; line-height du bouton lastmsg). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/features/flightdeck/CardPopover.tsx | 126 ++++++++++++++++++++ src/features/flightdeck/LastMessagePeek.tsx | 47 ++++++++ src/features/flightdeck/StateActions.tsx | 38 +++--- src/features/flightdeck/StreamCard.tsx | 53 ++++---- src/features/flightdeck/TodoPeek.tsx | 54 +++++++++ src/ui/conductor-flightdeck.css | 31 +++++ 6 files changed, 304 insertions(+), 45 deletions(-) create mode 100644 src/features/flightdeck/CardPopover.tsx create mode 100644 src/features/flightdeck/LastMessagePeek.tsx create mode 100644 src/features/flightdeck/TodoPeek.tsx diff --git a/src/features/flightdeck/CardPopover.tsx b/src/features/flightdeck/CardPopover.tsx new file mode 100644 index 0000000..f6e4a02 --- /dev/null +++ b/src/features/flightdeck/CardPopover.tsx @@ -0,0 +1,126 @@ +// A small popover anchored to a trigger inside a FlightDeck card — the shared shell +// for the "last message" and "to-do list" peeks. Rendered in a PORTAL, fixed- +// positioned, NOT as an absolute child: a card lives inside the swimlane's +// `overflow:auto` (`.ag-grid`), which would clip an in-flow popover. Same reasoning +// as BackgroundTaskBadge's popover. +// +// Placement is collision-aware so it is NEVER truncated by a screen edge: the left +// edge is anchored to the trigger and clamped into the viewport (a card on the far +// left opens rightward, one on the far right is pulled back in); it opens downward, +// flipping upward when there isn't room below. Width and height are clamped to the +// available space too. +// +// Escape closes and `preventDefault()`s — this popover OWNS the key while open, so a +// window-level Escape listener (the reply modal) that checks `defaultPrevented` won't +// also fire (keydown bubbles document→window). See the Escape convention in CLAUDE.md. + +import { + useEffect, + useLayoutEffect, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; +import { Ico } from "../../ui/kit"; + +interface Placement { + left: number; + top?: number; + bottom?: number; + width: number; + maxHeight: number; +} + +/** Margin kept between the popover and the viewport edges. */ +const M = 8; +/** Below-space (px) under which we prefer flipping above (unless above is even tighter). */ +const FLIP_THRESHOLD = 160; + +export function CardPopover({ + anchorRef, + open, + onClose, + width = 300, + title, + icon, + children, +}: { + anchorRef: RefObject<HTMLElement | null>; + open: boolean; + onClose: () => void; + /** Preferred width; shrunk to fit a narrow viewport. */ + width?: number; + /** Optional header label. */ + title?: string; + /** Optional header icon name. */ + icon?: string; + children: ReactNode; +}) { + const [pos, setPos] = useState<Placement | null>(null); + + // Compute placement synchronously when opening, from the trigger's live rect. + useLayoutEffect(() => { + if (!open) { + setPos(null); + return; + } + const r = anchorRef.current?.getBoundingClientRect(); + if (!r) return; + const vw = window.innerWidth; + const vh = window.innerHeight; + const w = Math.min(width, vw - 2 * M); + // Anchor the LEFT edge to the trigger, then clamp so neither edge spills off-screen. + const left = Math.min(Math.max(M, r.left), vw - w - M); + const belowSpace = vh - r.bottom - 10; + const aboveSpace = r.top - 10; + if (belowSpace >= FLIP_THRESHOLD || belowSpace >= aboveSpace) { + setPos({ left, top: r.bottom + 6, width: w, maxHeight: belowSpace }); + } else { + setPos({ left, bottom: vh - r.top + 6, width: w, maxHeight: aboveSpace }); + } + }, [open, anchorRef, width]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open || !pos) return null; + + return createPortal( + // Transparent backdrop: catches the click-away without dimming, exactly like the + // background-tasks popover. + <div className="ag-pop-backdrop" onClick={onClose}> + <div + className="ag-pop" + style={{ + position: "fixed", + left: pos.left, + top: pos.top, + bottom: pos.bottom, + width: pos.width, + maxHeight: pos.maxHeight, + }} + onClick={(e) => e.stopPropagation()} + role="dialog" + > + {title ? ( + <div className="ag-pop-title"> + {icon ? <Ico name={icon} className="sm" /> : null} + {title} + </div> + ) : null} + <div className="ag-pop-body">{children}</div> + </div> + </div>, + document.body, + ); +} diff --git a/src/features/flightdeck/LastMessagePeek.tsx b/src/features/flightdeck/LastMessagePeek.tsx new file mode 100644 index 0000000..8a1a3aa --- /dev/null +++ b/src/features/flightdeck/LastMessagePeek.tsx @@ -0,0 +1,47 @@ +// The card's "last message you sent" line, made clickable: the ≤6-word summary is a +// button that opens a popover with the FULL text of your latest message. The summary +// answers "what did I last ask this agent" at a glance; the peek shows the whole +// thing without leaving the deck. +// +// Full text comes from the message store (`useUserMessageHistory`, last entry): the +// summary is live-only — it only appears after a send THIS run, and a send always +// lands a user turn in the store — so the full message is always available. The +// summary is kept as a fallback for the rare case the history is momentarily empty. + +import { useRef, useState } from "react"; +import { Ico } from "../../ui/kit"; +import { useUserMessageHistory } from "../../store/conversationStore"; +import { CardPopover } from "./CardPopover"; + +export function LastMessagePeek({ convId, summary }: { convId: string; summary: string }) { + const [open, setOpen] = useState(false); + const btnRef = useRef<HTMLButtonElement>(null); + const history = useUserMessageHistory(convId); + const full = history.length ? history[history.length - 1] : summary; + + return ( + <> + <button + ref={btnRef} + type="button" + className="ag-lastmsg ag-lastmsg-btn" + onClick={() => setOpen((o) => !o)} + title="Voir le message envoyé" + > + <Ico name="reply" className="sm" /> + <span className="ag-lastmsg-txt">{summary}</span> + </button> + + <CardPopover + anchorRef={btnRef} + open={open} + onClose={() => setOpen(false)} + width={340} + title="Dernier message envoyé" + icon="reply" + > + <div className="ag-pop-msg">{full}</div> + </CardPopover> + </> + ); +} diff --git a/src/features/flightdeck/StateActions.tsx b/src/features/flightdeck/StateActions.tsx index 6437925..553d3ed 100644 --- a/src/features/flightdeck/StateActions.tsx +++ b/src/features/flightdeck/StateActions.tsx @@ -1,11 +1,11 @@ -// Contextual card actions. EVERY "open" action here opens the conversation in the -// Flight Deck REPLY MODAL, so you can read/answer in place without leaving the deck. -// The card TITLE (in StreamCard) stays the full-screen entry point, so both options -// are always available — even on a calm card. A permission is authorised/refused -// from inside the modal (its context is right there), not blindly on the card. "Vu" -// acknowledges inline (the opposite of opening). When the status says "blocked" but -// no request is actually queued yet (the awaiting vs queue race), we fall through to -// a plain "Ouvrir" (which also opens the modal). +// Contextual card actions — ONLY the ones that need prominence: "Répondre" (answer a +// permission / question) and "Vu" (acknowledge inline). Opening the conversation is no +// longer a button here: clicking the card BODY opens the reply modal, and the card +// TITLE (in StreamCard) stays the full-screen entry point. A permission is +// authorised/refused from inside that modal (its context is right there), not blindly +// on the card. When the status says "blocked" but no request is actually queued yet +// (the awaiting vs queue race), there's nothing to answer → no action row (the card +// body still opens it). import { Ico } from "../../ui/kit"; import type { AgentStatus } from "../../agent/status"; import { usePendingPermissions } from "../../store/conversationStore"; @@ -38,7 +38,7 @@ export function StateActions({ </div> ); } - // no live request yet → fall through to "Ouvrir" + // no live request yet → no action row (the card body still opens it) } else if (status.kind === "needInput") { if (status.via === "questionnaire") { const req = pending[0]; @@ -53,7 +53,7 @@ export function StateActions({ </div> ); } - // no live questionnaire request → fall through to "Ouvrir" + // no live questionnaire request → no action row (the card body still opens it) } else { // Open question (heuristic) — dismissable inline, or open the modal to reply. return ( @@ -70,27 +70,21 @@ export function StateActions({ ); } } else if (status.kind === "error" || status.kind === "review") { + // "Vu" acknowledges inline. Opening is now the card BODY's job (it opens the + // reply modal), so the redundant "Ouvrir" is gone. return ( <div className="ag-card-actions"> <button className="wf-btn ghost sm" onClick={() => acknowledgeConversation(convId)}> <Ico name="check" className="sm" /> Vu </button> - <button className="wf-btn prim sm" onClick={reply}> - Ouvrir - </button> </div> ); } // running / idle / off — and the "blocked but no request yet" fall-throughs — - // nothing to answer, but you can still peek/reply in place. "Ouvrir" opens the - // modal too; the card TITLE remains the full-screen entry point. - return ( - <div className="ag-card-actions"> - <button className="wf-btn ghost sm" onClick={reply}> - Ouvrir - </button> - </div> - ); + // nothing to answer. Opening in place is handled by clicking the card body (reply + // modal) and the card TITLE remains the full-screen entry point, so there's no + // action row to render here. + return null; } diff --git a/src/features/flightdeck/StreamCard.tsx b/src/features/flightdeck/StreamCard.tsx index 5c789ac..02fc904 100644 --- a/src/features/flightdeck/StreamCard.tsx +++ b/src/features/flightdeck/StreamCard.tsx @@ -2,19 +2,22 @@ // status the sidebar shows (useAgentStatus → agentStatusToDot/rowAttention), the // same todo summary, context fill and worktree badge. No bespoke data, no fake // chrome: every element is wired to the live store. -import { Dot, Pill, ContextMeter, TodoPips, Ico, type TodoSeg } from "../../ui/kit"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import { Dot, Pill, ContextMeter, Ico } from "../../ui/kit"; import { useAgentStatus } from "../../agent/useAgentStatus"; import { agentStatusToDot, rowAttention } from "../../agent/status"; import { effortLabel } from "../../agent/subagentMeta"; -import { useTodos, useTodoSummary, useSessionState } from "../../store/conversationStore"; +import { useSessionState } from "../../store/conversationStore"; import { useContextData } from "../../store/contextData"; import { useLastMessageSummary } from "../../store/lastMessageSummary"; import { WorktreeIndicator } from "../git/WorktreeIndicator"; import type { Conversation } from "../../store/conversationsStore"; -import type { TodoItem } from "../../store/types"; import { StateBlock } from "./StateBlock"; import { StateActions } from "./StateActions"; import { BackgroundTaskBadge } from "./BackgroundTaskBadge"; +import { LastMessagePeek } from "./LastMessagePeek"; +import { TodoPeek } from "./TodoPeek"; +import { useFlightdeckModal } from "./flightdeckModalStore"; /** Relative "last activity" stamp — "il y a 14 min" / "il y a 2 h". `now` comes from * the grid's shared ticker so idle/off cards advance without a per-card timer. */ @@ -28,11 +31,6 @@ function fmtAgo(ts: number, now: number): string { return `il y a ${Math.floor(h / 24)} j`; } -/** Map a todo's status to its pip colour (grey / amber / green). */ -function todoSeg(t: TodoItem): TodoSeg { - return t.status === "completed" ? "done" : t.status === "in_progress" ? "doing" : "todo"; -} - export function StreamCard({ conv, repoPath, @@ -47,9 +45,8 @@ export function StreamCard({ const status = useAgentStatus(conv.id); const dot = agentStatusToDot(status); const attn = rowAttention(status); - const todos = useTodos(conv.id); - const summary = useTodoSummary(conv.id); const { ctx, ready } = useContextData(conv.id); + const openModal = useFlightdeckModal((s) => s.open); // The agent's live reasoning effort (get_settings read-back) — same data the // conversation composer's gauge shows, surfaced read-only on the card. Null until // the session has reported settings (never spawned this run → no chip). @@ -61,13 +58,32 @@ export function StreamCard({ const lastMsg = useLastMessageSummary(conv.id); const cls = - "wf-card ag-card" + + "wf-card ag-card ag-card-clickable" + (attn === "input" || attn === "error" ? " att" : "") + (attn === "review" ? " rev" : "") + (status.kind === "off" ? " dim" : ""); + // Clicking the card BODY opens the conversation in the reply modal — the same as + // the (now removed) plain "Ouvrir" button. The card TITLE stays the full-screen + // entry point. + const onCardClick = (e: ReactMouseEvent<HTMLDivElement>) => { + const target = e.target as HTMLElement; + // Ignore a click that bubbled from a PORTAL opened within the card (the last-message + // / to-do peek popovers, the background-tasks popover). React bubbles portal events + // through the component tree, so a click-away to CLOSE such a popover would otherwise + // reach here and open the conversation — the portal's target isn't a DOM descendant + // of the card, so `contains` rejects it. + if (!e.currentTarget.contains(target)) return; + // Any interactive child (title, action buttons, worktree/bg badges, the peek + // triggers — all real <button>s) handles its own click. + if (target.closest("button, a, input, textarea, select, label")) return; + // A text selection isn't a click. + if (!window.getSelection()?.isCollapsed) return; + openModal(conv.id); + }; + return ( - <div className={cls}> + <div className={cls} onClick={onCardClick}> <div className="ag-card-h"> <Dot s={dot} pulse /> <button className="ag-card-name" onClick={() => onOpen(conv.id)} title={conv.name}> @@ -86,22 +102,13 @@ export function StreamCard({ ) : null} </div> - {lastMsg ? ( - <div className="ag-lastmsg" title={lastMsg}> - <Ico name="reply" className="sm" /> - <span className="ag-lastmsg-txt">{lastMsg}</span> - </div> - ) : null} + {lastMsg ? <LastMessagePeek convId={conv.id} summary={lastMsg} /> : null} <StateBlock convId={conv.id} status={status} /> <div className="ag-card-foot"> {ready ? <ContextMeter ctx={ctx} /> : null} - {summary.total > 0 ? ( - // Cap the pip count so a huge plan can't overflow the footer; the - // "done/total" ratio still carries the full number. - <TodoPips segs={todos.slice(0, 20).map(todoSeg)} done={summary.completed} total={summary.total} /> - ) : null} + <TodoPeek convId={conv.id} /> <BackgroundTaskBadge convId={conv.id} /> <span className="wf-row" style={{ gap: 5, marginLeft: "auto" }} title="Dernière activité"> <Ico name="clock" className="sm" /> diff --git a/src/features/flightdeck/TodoPeek.tsx b/src/features/flightdeck/TodoPeek.tsx new file mode 100644 index 0000000..4c394e5 --- /dev/null +++ b/src/features/flightdeck/TodoPeek.tsx @@ -0,0 +1,54 @@ +// The card's to-do progress pips, made clickable: the compact bar is a button that +// opens a popover with the full plan — the SAME <TodoList> the conversation view's +// TodoBar renders, so a card and its thread show the plan identically. +// +// Renders nothing until the agent has written a list (summary.total === 0), matching +// the footer's previous guard. + +import { useRef, useState } from "react"; +import { TodoPips, type TodoSeg } from "../../ui/kit"; +import { useTodos, useTodoSummary } from "../../store/conversationStore"; +import type { TodoItem } from "../../store/types"; +import { TodoList } from "../todos/TodoList"; +import { CardPopover } from "./CardPopover"; + +/** Map a todo's status to its pip colour (grey / amber / green). */ +function todoSeg(t: TodoItem): TodoSeg { + return t.status === "completed" ? "done" : t.status === "in_progress" ? "doing" : "todo"; +} + +export function TodoPeek({ convId }: { convId: string }) { + const todos = useTodos(convId); + const summary = useTodoSummary(convId); + const [open, setOpen] = useState(false); + const btnRef = useRef<HTMLButtonElement>(null); + + if (summary.total === 0) return null; + + return ( + <> + <button + ref={btnRef} + type="button" + className="ag-todo-btn" + onClick={() => setOpen((o) => !o)} + title="Voir la liste des tâches" + > + {/* Cap the pip count so a huge plan can't overflow the footer; the + "done/total" ratio still carries the full number. */} + <TodoPips segs={todos.slice(0, 20).map(todoSeg)} done={summary.completed} total={summary.total} /> + </button> + + <CardPopover + anchorRef={btnRef} + open={open} + onClose={() => setOpen(false)} + width={320} + title="Tâches" + icon="list" + > + <TodoList todos={todos} /> + </CardPopover> + </> + ); +} diff --git a/src/ui/conductor-flightdeck.css b/src/ui/conductor-flightdeck.css index 0cf2b09..adc6618 100644 --- a/src/ui/conductor-flightdeck.css +++ b/src/ui/conductor-flightdeck.css @@ -42,6 +42,17 @@ .ag-grid::-webkit-scrollbar-thumb { background:var(--wf-line-2); border-radius:999px; border:2px solid transparent; background-clip:content-box; } .ag-grid::-webkit-scrollbar-track { background:transparent; } .ag-card { padding:14px; display:flex; flex-direction:column; gap:11px; } +/* The whole card body is clickable (→ opens the reply modal); the title and every + action/badge inside handle their own click. Signal it with a pointer + a subtle + border lift on hover, without competing with the attention/review accents. */ +.ag-card-clickable { cursor:pointer; transition:border-color .12s ease, background-color .12s ease; } +.ag-card-clickable:hover { background:var(--wf-panel-2); } +/* The border lift is scoped AWAY from accent cards: `.wf-card.att` (attention/error) and + `.ag-card.rev` (review) carry a coloured border of equal (0,2,0) specificity, and this + file is imported after wirekit — so an unscoped hover would win the tie and neutralise + the amber/blue accent on exactly the cards meant to stand out. `:not(.att):not(.rev)` + keeps the lift for calm cards only. */ +.ag-card-clickable:not(.att):not(.rev):hover { border-color:var(--wf-line-2); } .ag-card.dim { opacity:.58; } .ag-card.rev { border-color:#314463; } .ag-card-h { display:flex; align-items:center; gap:9px; } @@ -57,6 +68,12 @@ under the tags. A small self-hugging box (not full width) with a coral "reply" arrow marking it as YOUR voice, distinct from the agent's activity line below. Live-only. */ .ag-lastmsg { align-self:flex-start; display:inline-flex; align-items:flex-start; gap:7px; max-width:100%; min-width:0; padding:4px 9px; border:1px solid var(--wf-line); border-radius:var(--wf-r); color:var(--wf-tx); font-size:11.5px; line-height:1.35; } +/* The line is a button (click → popover with the full message): reset the native + button chrome and add a clickable hover. */ +/* `font:inherit` also resets line-height, so re-assert the box's 1.35 (the shorthand + would otherwise inherit 1.5 from .wf-win and loosen the 2-line clamp). */ +.ag-lastmsg-btn { font:inherit; font-size:11.5px; line-height:1.35; text-align:left; background:transparent; cursor:pointer; transition:border-color .12s ease, background-color .12s ease; } +.ag-lastmsg-btn:hover { border-color:var(--wf-line-2); background:var(--wf-bg-2); } .ag-lastmsg .wf-ico { width:13px; height:13px; margin-top:1px; color:var(--wf-accent); } /* The ≤6-word summary is never ellipsized: it wraps to a 2nd line rather than clip (a 2-line clamp is only a safety net for a misbehaving model, never a normal summary). */ @@ -101,3 +118,17 @@ .ag-bgpop-row .wf-dot { flex:0 0 auto; } .ag-bgpop-label { flex:1; min-width:0; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .ag-bgpop-meta { flex:0 0 auto; font-size:10px; color:var(--wf-tx-lo); } + +/* --- clickable to-do pips on the card footer (→ popover with the full plan) --- */ +.ag-todo-btn { display:inline-flex; align-items:center; font:inherit; padding:2px 5px; margin:-2px -5px; border:0; border-radius:7px; background:transparent; color:inherit; cursor:pointer; transition:background .12s ease; } +.ag-todo-btn:hover { background:var(--wf-bg-2); } + +/* --- generic card peek popover (last message / to-do list) --- */ +/* Transparent backdrop: catches the click-away without dimming (same as .ag-bgpop-backdrop). */ +.ag-pop-backdrop { position:fixed; inset:0; z-index:150; } +.ag-pop { z-index:151; display:flex; flex-direction:column; overflow:hidden; background:var(--wf-panel-2); border:1px solid var(--wf-line-2); border-radius:10px; box-shadow:0 14px 36px -12px #000, 0 0 0 1px #0006; } +.ag-pop-title { flex:0 0 auto; display:flex; align-items:center; gap:6px; padding:8px 11px; font-size:10.5px; font-weight:600; letter-spacing:.3px; text-transform:uppercase; color:var(--wf-tx-xlo); border-bottom:1px solid var(--wf-line); } +.ag-pop-title .wf-ico { width:12px; height:12px; } +.ag-pop-body { flex:1 1 auto; overflow-y:auto; overscroll-behavior:contain; padding:11px; } +/* Full last message: preserve the sender's line breaks, wrap long lines, keep it legible. */ +.ag-pop-msg { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12.5px; line-height:1.5; color:var(--wf-tx); } From 1a60c45dd984ae2b4766be9835d1a90c13bb050e Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Thu, 2 Jul 2026 22:45:17 +0200 Subject: [PATCH 11/91] =?UTF-8?q?feat(composer):=20branche=20le=20bouton?= =?UTF-8?q?=20=C2=AB=20+=20=C2=BB=20=E2=80=94=20joindre=20fichiers/images?= =?UTF-8?q?=20=C3=A0=20un=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bouton « + » du composeur ouvre un file picker natif (multi-sélection) : - images (png/jpeg/gif/webp) → bloc image base64 sur le wire stream-json (vérifié accepté par claude 2.1.187) + vignette optimiste dans la bulle ; - tout autre fichier → mention de chemin insérée dans le message ; - collage d'image (Cmd+V) → attachement direct. Socle : send_message(session, text, images: Vec<ImageAttachment>), transport::user_message_with_images (texte puis blocs image), SessionCommand::SendUser. Bindings tauri-specta regénérés. Store d'attachements en mémoire par conversation. Corrige aussi les 6 findings de la revue adversariale : - nettoyage des attachements sur suppression conv/repo + wipe-all (fuite mémoire) ; - plafond de taille au collage (16 Mio, aligné sur fs::MAX_FILE_BYTES) ; - consommation de l'uuid d'écho pour un tour image-seule (fuite bornée) ; - verrou d'envoi tant qu'un collage se lit (race collage→message suivant) ; - rendu des images dans InlineUserMarker (clean-output) ; - conv à 1er message image-seule listée/indexée + placeholder [image] au reload. tsc + vitest (650) + cargo test --lib (215) + build : verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src-tauri/src/ipc/commands.rs | 9 +- src-tauri/src/supervisor/assembler.rs | 16 +- src-tauri/src/supervisor/history.rs | 32 ++- src-tauri/src/supervisor/session.rs | 31 ++- src-tauri/src/supervisor/transport.rs | 85 +++++++- .../conversation/ConductorComposer.tsx | 182 ++++++++++++++++-- src/features/conversation/ConductorThread.tsx | 53 ++++- .../conversation/composerAttachments.test.ts | 131 +++++++++++++ .../conversation/composerAttachments.ts | 175 +++++++++++++++++ src/ipc/bindings.ts | 24 ++- src/ipc/mock/mockBindings.ts | 6 +- src/ipc/useCommands.ts | 23 ++- src/store/conversationStore.ts | 6 +- src/store/conversationsStore.ts | 7 + src/store/types.ts | 14 ++ src/ui/conductor-conversation.css | 17 +- 16 files changed, 756 insertions(+), 55 deletions(-) create mode 100644 src/features/conversation/composerAttachments.test.ts create mode 100644 src/features/conversation/composerAttachments.ts diff --git a/src-tauri/src/ipc/commands.rs b/src-tauri/src/ipc/commands.rs index 046b35d..475ec81 100644 --- a/src-tauri/src/ipc/commands.rs +++ b/src-tauri/src/ipc/commands.rs @@ -15,7 +15,7 @@ use crate::supervisor::model::{ ContextFill, ConversationItem, SlashCommand, WorkflowJournal, WorkflowPhase, WorkflowRun, }; use crate::supervisor::session::{self, InitialControls, SessionHandle}; -use crate::supervisor::transport::SpawnConfig; +use crate::supervisor::transport::{ImageAttachment, SpawnConfig}; use crate::usage::{PlanUsage, UsageError}; /// Typed return value of `ping`. Proves React -> Rust (typed command). @@ -377,16 +377,19 @@ pub async fn get_plan_usage() -> Result<PlanUsage, UsageError> { crate::usage::fetch_plan_usage().await } -/// Send a user turn to a session. +/// Send a user turn to a session: the typed `text` plus any joined `images` (sent as +/// `image` blocks in the message `content` array). `images` is empty for a plain text +/// turn. #[tauri::command] #[specta::specta] pub async fn send_message( sessions: tauri::State<'_, Sessions>, session: String, text: String, + images: Vec<ImageAttachment>, ) -> Result<(), String> { let handle = sessions.get(&session).ok_or_else(unknown_session)?; - handle.send_user_text(text).await.map_err(|e| e.to_string()) + handle.send_user(text, images).await.map_err(|e| e.to_string()) } /// Answer a pending `can_use_tool` permission prompt (allow / deny). diff --git a/src-tauri/src/supervisor/assembler.rs b/src-tauri/src/supervisor/assembler.rs index 12c90bd..18d1251 100644 --- a/src-tauri/src/supervisor/assembler.rs +++ b/src-tauri/src/supervisor/assembler.rs @@ -854,12 +854,18 @@ impl Assembler { } _ => {} } - if !text.trim().is_empty() { + { let uuid = u.uuid.clone().unwrap_or_default(); - // Suppress the echo of a turn WE sent (`--replay-user-messages` returns it - // with the uuid we stamped) — the UI shows our own messages optimistically. - // A remote (phone/web) turn carries a uuid we never sent, so it is surfaced. - if !self.sent_user_uuids.remove(&uuid) { + // Consume the echo bookkeeping FIRST, regardless of content: a turn WE sent + // (`--replay-user-messages` returns it with the uuid we stamped) is recognised + // and dropped from `sent_user_uuids` here. Doing this outside the text guard + // matters for an images-only turn (empty text + image blocks): its uuid would + // otherwise linger in the set forever (a slow leak on a long-lived session). + let was_ours = !uuid.is_empty() && self.sent_user_uuids.remove(&uuid); + // Only surface a bubble for a genuine remote turn WITH text — the UI already + // shows our own turns optimistically, and image-only content isn't rendered on + // the live/replay path (an accepted limit). + if !was_ours && !text.trim().is_empty() { out.push(SessionEvent::Item(ConversationItem::UserMessage { id: uuid, text, diff --git a/src-tauri/src/supervisor/history.rs b/src-tauri/src/supervisor/history.rs index 064aa3a..47f8744 100644 --- a/src-tauri/src/supervisor/history.rs +++ b/src-tauri/src/supervisor/history.rs @@ -258,6 +258,7 @@ fn push_user(entry: &Value, items: &mut Vec<ConversationItem>) { Value::String(text) => push_user_text(&uuid, text, items), Value::Array(blocks) => { let mut text = String::new(); + let mut has_image = false; for b in blocks { match b.get("type").and_then(Value::as_str) { Some("text") => { @@ -268,6 +269,7 @@ fn push_user(entry: &Value, items: &mut Vec<ConversationItem>) { text.push_str(t); } } + Some("image") => has_image = true, Some("tool_result") => items.push(ConversationItem::ToolResult { tool_use_id: b .get("tool_use_id") @@ -281,7 +283,14 @@ fn push_user(entry: &Value, items: &mut Vec<ConversationItem>) { _ => {} } } - push_user_text(&uuid, &text, items); + // An image-only human turn (no text block) still gets a bubble on reload — + // a "[image]" placeholder, consistent with the history-list excerpt — instead + // of vanishing (the base64 itself is never rendered as text). + if text.trim().is_empty() && has_image { + push_user_text(&uuid, "[image]", items); + } else { + push_user_text(&uuid, &text, items); + } } _ => {} } @@ -559,16 +568,27 @@ fn first_user_text(entry: &Value) -> Option<String> { Value::String(s) => s.clone(), Value::Array(blocks) => { let mut t = String::new(); + let mut has_image = false; for b in blocks { - if b.get("type").and_then(Value::as_str) == Some("text") { - if let Some(s) = b.get("text").and_then(Value::as_str) { - if !t.is_empty() { - t.push(' '); + match b.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(s) = b.get("text").and_then(Value::as_str) { + if !t.is_empty() { + t.push(' '); + } + t.push_str(s); } - t.push_str(s); } + Some("image") => has_image = true, + _ => {} } } + // An image-only human turn (e.g. a screenshot sent with no caption) is real + // content — give it a placeholder excerpt so the conversation is still listed + // and indexed, instead of being discarded as an empty "noise" session. + if t.trim().is_empty() && has_image { + return Some("[image]".to_string()); + } t } _ => return None, diff --git a/src-tauri/src/supervisor/session.rs b/src-tauri/src/supervisor/session.rs index 92d5d76..c7f7368 100644 --- a/src-tauri/src/supervisor/session.rs +++ b/src-tauri/src/supervisor/session.rs @@ -29,7 +29,12 @@ use super::transport::{self, SpawnConfig, Transport, TransportError}; /// A command sent from the UI to a running session. pub enum SessionCommand { - SendUserText(String), + /// A user turn: the typed text plus any images joined to it (sent as `image` + /// blocks in the message `content` array). `images` is empty for a plain text turn. + SendUser { + text: String, + images: Vec<transport::ImageAttachment>, + }, AnswerPermission { request_id: String, decision: PermissionDecision, @@ -195,8 +200,18 @@ pub struct SessionHandle { } impl SessionHandle { + /// Send a user turn: text plus any joined images. `send_user_text` is the + /// text-only convenience used by internal callers and tests. + pub async fn send_user( + &self, + text: impl Into<String>, + images: Vec<transport::ImageAttachment>, + ) -> Result<(), SessionError> { + self.send(SessionCommand::SendUser { text: text.into(), images }).await + } + pub async fn send_user_text(&self, text: impl Into<String>) -> Result<(), SessionError> { - self.send(SessionCommand::SendUserText(text.into())).await + self.send_user(text, Vec::new()).await } pub async fn answer_permission( @@ -478,7 +493,7 @@ impl SessionCore { // Only track the ack if the line actually went out. If the outbound channel // is closed (process gone, not yet observed), surface it as a control error // instead of silently dropping the request and leaking a pending entry that - // will never be acked — same "no silent failure" guard as SendUserText. + // will never be acked — same "no silent failure" guard as SendUser. if self.send(line) { self.pending_control.insert(rid, kind); } else { @@ -827,12 +842,12 @@ impl SessionCore { fn on_command(&mut self, cmd: SessionCommand) { match cmd { - SessionCommand::SendUserText(text) => { + SessionCommand::SendUser { text, images } => { // Stamp a uuid so `--replay-user-messages` echo of THIS turn can be // recognised as our own and suppressed (the UI shows it optimistically); // a remote turn carries a uuid we never recorded, so it surfaces live. let uuid = uuid::Uuid::new_v4().to_string(); - if self.send(transport::user_message(text, &uuid)) { + if self.send(transport::user_message_with_images(text, &images, &uuid)) { self.assembler.note_sent_user_message(&uuid); let ev = self.assembler.set_busy(true); self.emit(ev); @@ -1183,7 +1198,7 @@ mod tests { fn send_user_text_on_a_dead_session_surfaces_a_notice() { let (mut core, mut events, out) = test_core(); drop(out); // the process is gone: the outbound channel is closed - core.on_command(SessionCommand::SendUserText("hello".to_string())); + core.on_command(SessionCommand::SendUser { text: "hello".to_string(), images: Vec::new() }); let notice = drain(&mut events).into_iter().find_map(|e| match e { SessionEvent::Item(ConversationItem::Notice { subtype, .. }) => Some(subtype), _ => None, @@ -1399,7 +1414,7 @@ mod tests { #[test] fn send_user_text_writes_a_user_message() { let (mut core, _events, mut out) = test_core(); - core.on_command(SessionCommand::SendUserText("hello".to_string())); + core.on_command(SessionCommand::SendUser { text: "hello".to_string(), images: Vec::new() }); let lines = drain(&mut out); assert_eq!(lines[0]["type"], json!("user")); assert_eq!(lines[0]["message"]["content"][0]["text"], json!("hello")); @@ -1414,7 +1429,7 @@ mod tests { #[test] fn own_user_message_echo_suppressed_remote_surfaced() { let (mut core, mut events, mut out) = test_core(); - core.on_command(SessionCommand::SendUserText("hello".to_string())); + core.on_command(SessionCommand::SendUser { text: "hello".to_string(), images: Vec::new() }); let uuid = drain(&mut out)[0]["uuid"].as_str().unwrap().to_string(); let _ = drain(&mut events); // the busy state event from the send diff --git a/src-tauri/src/supervisor/transport.rs b/src-tauri/src/supervisor/transport.rs index 22df128..d3a97da 100644 --- a/src-tauri/src/supervisor/transport.rs +++ b/src-tauri/src/supervisor/transport.rs @@ -149,19 +149,56 @@ fn known_claude_locations() -> Vec<PathBuf> { out } +/// An image joined to a user turn: base64 bytes + their MIME type. Sent inside the +/// message `content` array as an `image` block (spec §3.10) — verified accepted by +/// `claude` 2.1.187, which "sees" it and answers about its content. The `data` field +/// is raw base64 (NO `data:` URL prefix). Also an IPC command param (`send_message`), +/// so it derives `specta::Type` for the generated TS bindings. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct ImageAttachment { + /// MIME type, e.g. `image/png`, `image/jpeg`, `image/gif`, `image/webp`. + pub media_type: String, + /// Base64-encoded image bytes, with NO `data:image/...;base64,` prefix. + pub data: String, +} + /// Build a `user` turn message in the Anthropic message shape (spec §2.3), stamped /// with `uuid`. The uuid is echoed back verbatim by `--replay-user-messages` /// (`isReplay:true`), which is how the core recognises — and suppresses — the echo of /// a turn WE sent (vs a remote turn, whose uuid we never sent). Mirrors the official /// extension, which sends its own `crypto.randomUUID()` and dedupes the replay by it. pub fn user_message(text: impl Into<String>, uuid: &str) -> Value { + user_message_with_images(text, &[], uuid) +} + +/// Build a `user` turn with an optional text block followed by any joined images +/// (`image` blocks). `content` is an ARRAY of blocks (spec §3.10): the text block is +/// included only when non-empty, so an images-only turn carries just the image blocks. +/// An all-empty turn can't happen from the UI (empty sends are gated), but we still +/// emit a single empty text block as a defensive floor so `content` is never `[]`. +pub fn user_message_with_images( + text: impl Into<String>, + images: &[ImageAttachment], + uuid: &str, +) -> Value { + let text = text.into(); + let mut content: Vec<Value> = Vec::new(); + if !text.is_empty() { + content.push(json!({ "type": "text", "text": text })); + } + for img in images { + content.push(json!({ + "type": "image", + "source": { "type": "base64", "media_type": img.media_type, "data": img.data }, + })); + } + if content.is_empty() { + content.push(json!({ "type": "text", "text": "" })); + } json!({ "type": "user", "uuid": uuid, - "message": { - "role": "user", - "content": [{ "type": "text", "text": text.into() }], - } + "message": { "role": "user", "content": content } }) } @@ -606,6 +643,46 @@ mod tests { assert_eq!(cfg.cwd, PathBuf::from("/tmp")); } + #[test] + fn plain_user_message_is_a_single_text_block() { + let v = user_message("hi", "u3"); + assert_eq!(v["type"], "user"); + assert_eq!(v["uuid"], "u3"); + let content = v["message"]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "hi"); + } + + #[test] + fn user_message_with_images_puts_text_then_image_blocks() { + let imgs = vec![ImageAttachment { + media_type: "image/png".into(), + data: "AAAA".into(), + }]; + let v = user_message_with_images("hello", &imgs, "u1"); + let content = v["message"]["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "hello"); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!(content[1]["source"]["data"], "AAAA"); + } + + #[test] + fn images_only_turn_omits_the_empty_text_block() { + let imgs = vec![ImageAttachment { + media_type: "image/jpeg".into(), + data: "BBBB".into(), + }]; + let v = user_message_with_images("", &imgs, "u2"); + let content = v["message"]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1, "an empty text block must not be sent"); + assert_eq!(content[0]["type"], "image"); + } + /// A vanished cwd (e.g. a conversation whose worktree was deleted) must report /// `CwdMissing` — NOT the misleading "claude binary not found" — and must do so /// BEFORE spawning, so no real `claude` is needed for this test. diff --git a/src/features/conversation/ConductorComposer.tsx b/src/features/conversation/ConductorComposer.tsx index 9708e35..6ccfe58 100644 --- a/src/features/conversation/ConductorComposer.tsx +++ b/src/features/conversation/ConductorComposer.tsx @@ -5,9 +5,12 @@ import { useMemo, useRef, useState, + type ClipboardEvent, type KeyboardEvent, } from "react"; import type { PermissionMode, SlashCommand } from "../../ipc/client"; +import type { UserTurnImage } from "../../store/types"; +import { isTauri } from "../../ipc/provider"; import { useShallow } from "zustand/react/shallow"; import { useInterrupt, useSendMessage } from "../../ipc/useCommands"; import { useSessionState, useUserMessageHistory } from "../../store/conversationStore"; @@ -47,6 +50,15 @@ import { type HistoryNav, type RecallResult, } from "./messageHistory"; +import { + attachmentFromBlob, + attachmentFromPath, + attachmentsFor, + imageDataUrl, + useComposerAttachments, + useConvAttachments, + wireImageMimeForPath, +} from "./composerAttachments"; import styles from "./ConductorComposer.module.css"; // The real Claude models. Wire value = CLI alias (sent verbatim to set_model and @@ -163,6 +175,17 @@ export const ConductorComposer = forwardRef< }); const taRef = useRef<HTMLTextAreaElement>(null); + // ---- Attachments (the "+" button + paste-an-image) ---------------------- + // Joined images for THIS conversation (in-memory, per-conv; see composerAttachments). + const attachments = useConvAttachments(session); + // Last attach failure (unreadable / too large / unsupported), shown inline in the + // attachment row until the next successful attach or send. + const [attachErr, setAttachErr] = useState<string | null>(null); + // In-flight pasted-image reads (FileReader is async). While > 0 the send is blocked + // so a fast paste-then-Enter can't fire BEFORE the image lands (which would send + // without it, then attach it to the NEXT message). + const [attaching, setAttaching] = useState(0); + // ---- Shell-style ↑/↓ history recall ------------------------------------- // The user's own previously-sent messages, oldest→newest (see selector). The // navigation cursor is a ref (no re-render needed) and resets on remount — the @@ -337,6 +360,93 @@ export const ConductorComposer = forwardRef< ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`; }; + // Make an absolute path relative to the conversation cwd when it lives under it, so + // an inserted file mention stays short + resolves to a clickable chip; else keep it + // absolute (still readable by Claude and by the mention resolver). + const relForCwd = (abs: string): string => { + const base = cwd ? cwd.replace(/\/+$/, "") : ""; + return base && abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs; + }; + + // Append file-path mentions to the draft (space-separated), caret at the end. + const insertMentions = (paths: string[]) => { + if (!paths.length) return; + const joined = paths.map(relForCwd).join(" "); + const next = text.trim() ? `${text.replace(/\s*$/, "")} ${joined} ` : `${joined} `; + setText(next); + requestAnimationFrame(() => { + const ta = taRef.current; + if (!ta) return; + ta.focus(); + const end = ta.value.length; + ta.setSelectionRange(end, end); + autoGrow(); + }); + }; + + // Route picked paths: model-attachable images → base64 attachment; anything else → + // a path mention Claude reads with its own tools. + const addPaths = async (paths: string[]) => { + const mentions: string[] = []; + for (const p of paths) { + if (wireImageMimeForPath(p)) { + const res = await attachmentFromPath(p); + if (res && "error" in res) setAttachErr(res.error); + else if (res) useComposerAttachments.getState().add(session, res); + } else { + mentions.push(p); + } + } + insertMentions(mentions); + }; + + // The "+" button: native multi-file picker (any file / image). In the dev/browser + // mock there's no native dialog, so fall back to a path prompt (mention only). + const pickAndAttach = async () => { + setAttachErr(null); + let paths: string[] = []; + if (isTauri) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const sel = await open({ multiple: true, title: "Joindre des fichiers ou des images" }); + paths = Array.isArray(sel) ? sel : sel ? [sel] : []; + } else { + const p = window.prompt("Chemin du fichier à joindre :", ""); + paths = p && p.trim() ? [p.trim()] : []; + } + await addPaths(paths); + }; + + // Paste an image (screenshot / copied file) → attachment. Only preventDefault when + // we actually consumed image data, so plain text paste is untouched. + const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => { + const items = e.clipboardData?.items; + if (!items) return; + const blobs: File[] = []; + for (const it of Array.from(items)) { + if (it.kind === "file" && it.type.startsWith("image/")) { + const f = it.getAsFile(); + if (f) blobs.push(f); + } + } + if (!blobs.length) return; + e.preventDefault(); + setAttachErr(null); + setAttaching((n) => n + 1); + void (async () => { + try { + for (const b of blobs) { + const name = b.name && b.name.trim() ? b.name : "Image collée"; + const res = await attachmentFromBlob(b, name); + if (res && "error" in res) setAttachErr(res.error); + else if (res) useComposerAttachments.getState().add(session, res); + else setAttachErr("Format d'image non supporté (png, jpeg, gif, webp)."); + } + } finally { + setAttaching((n) => Math.max(0, n - 1)); + } + })(); + }; + // A restored draft can be multi-line; the textarea defaults to one row, so size it // to the content on mount (and whenever we switch to another conversation's draft) // rather than leaving it scrolled. autoGrow() reads taRef, set by render time. @@ -345,9 +455,10 @@ export const ConductorComposer = forwardRef< // eslint-disable-next-line react-hooks/exhaustive-deps }, [session]); - const sendText = (raw: string) => { - const t = raw.trim(); - if (!t) return; + // Core send: the typed text plus any joined images. Empty-empty is a no-op (the + // send button is gated on it too), but text-empty-with-images IS a valid send. + const sendMessageNow = (t: string, images: UserTurnImage[]) => { + if (!t && images.length === 0) return; // NO busy gate: sending while the agent is working is supported and desirable. // The `claude` CLI natively queues a user message received mid-turn and injects // it at the next loop boundary (after a tool_result batch, before the next model @@ -360,12 +471,16 @@ export const ConductorComposer = forwardRef< // The worktree toggle only applies to the very first spawn of a conversation. // `queued`: busy at send time → the CLI will inject this mid-turn, so the // bubble shows an "en attente" badge until the turn ends. - send.mutate({ text: t, worktree: useWorktree && isFresh, queued: busy }); + send.mutate({ text: t, images, worktree: useWorktree && isFresh, queued: busy }); // `/reload-skills` makes the CLI re-scan on-disk skills; mirror that in the // `/` menu by re-fetching this cwd's catalogue (a fresh spawn reads disk // afresh), overwriting the once-per-session cache. Fire-and-forget. if (isReloadSkillsCommand(t)) void refetchSlashCommands(cwd); setText(""); + // Joined images were consumed by this send — drop them so they don't ride the + // next message. + useComposerAttachments.getState().clear(session); + setAttachErr(null); histNav.current = IDLE_NAV; setSlashToken(null); requestAnimationFrame(autoGrow); @@ -374,7 +489,24 @@ export const ConductorComposer = forwardRef< onSent?.(); }; - const doSend = () => sendText(text); + // Text-only send (slash-command run, `/compact`) — never carries attachments. + const sendText = (raw: string) => sendMessageNow(raw.trim(), []); + + // The composer's primary send (button / Enter): text + this conversation's + // joined images, stripped of their local ids for the wire + optimistic bubble. + const doSend = () => { + // A pasted image is still being read — don't send yet, or it would go out on the + // NEXT message instead (the async add lands after this send's clear). + if (attaching > 0) return; + sendMessageNow( + text.trim(), + attachmentsFor(session).map((a) => ({ + mediaType: a.mediaType, + dataBase64: a.dataBase64, + name: a.name, + })), + ); + }; /** * Accept a slash command from the menu. Mirrors the VS Code extension: @@ -499,8 +631,34 @@ export const ConductorComposer = forwardRef< onPick={(cmd) => pickCommand(cmd, false)} /> ) : null} + {attachments.length > 0 || attachErr ? ( + <div className="cv-attach-row"> + {attachments.map((a) => ( + <div key={a.id} className="cv-attach" title={a.name}> + <img className="cv-attach-thumb" src={imageDataUrl(a)} alt={a.name ?? "image"} /> + <span className="cv-attach-name">{a.name ?? "image"}</span> + <button + type="button" + className="cv-attach-x" + title="Retirer" + aria-label="Retirer la pièce jointe" + onClick={() => useComposerAttachments.getState().remove(session, a.id)} + > + <Ico name="x" className="sm" /> + </button> + </div> + ))} + {attachErr ? <span className="cv-attach-err">{attachErr}</span> : null} + </div> + ) : null} <div className="cv-input"> - <button className="cv-add" disabled title="Joindre — à venir"> + <button + type="button" + className="cv-add" + onClick={() => void pickAndAttach()} + title="Joindre un fichier ou une image" + aria-label="Joindre un fichier ou une image" + > <Ico name="plus" className="sm" /> </button> <textarea @@ -524,12 +682,14 @@ export const ConductorComposer = forwardRef< }} onSelect={(e) => syncSlashToken(e.currentTarget)} onKeyDown={onKeyDown} + onPaste={onPaste} aria-label="Message" /> - {/* While busy with an empty box, the action is "interrupt". As soon as there - is text to send — busy or not — it's a send button: a message sent mid-turn - is natively queued by the CLI and injected at the next loop boundary. */} - {busy && !text.trim() ? ( + {/* While busy with an empty box (no text AND no attachments), the action is + "interrupt". As soon as there is something to send — text or a joined image, + busy or not — it's a send button: a message sent mid-turn is natively queued + by the CLI and injected at the next loop boundary. */} + {busy && !text.trim() && attachments.length === 0 && attaching === 0 ? ( <button className="cv-send" onClick={() => interrupt.mutate()} title="Interrompre"> <Ico name="stop" className="sm" /> </button> @@ -537,7 +697,7 @@ export const ConductorComposer = forwardRef< <button className="cv-send" onClick={doSend} - disabled={!text.trim()} + disabled={(!text.trim() && attachments.length === 0) || attaching > 0} title={busy ? "Envoyer — l'agent le traitera en cours de route" : "Envoyer"} > <Ico name="send" className="sm" /> diff --git a/src/features/conversation/ConductorThread.tsx b/src/features/conversation/ConductorThread.tsx index e183a7a..97fcd15 100644 --- a/src/features/conversation/ConductorThread.tsx +++ b/src/features/conversation/ConductorThread.tsx @@ -25,7 +25,8 @@ import { useTurn, useTurnResult, } from "../../store/conversationStore"; -import type { RoundMarker } from "../../store/types"; +import type { RoundMarker, UserTurnImage } from "../../store/types"; +import { imageDataUrl } from "./composerAttachments"; import { useConversationsStore } from "../../store/conversationsStore"; import { useBackgroundTasksStore, useTaskByToolUse } from "../../store/backgroundTasksStore"; import { fmtDuration, isBackgroundAgentInput, shortModel } from "../../agent/subagentMeta"; @@ -66,7 +67,15 @@ import styles from "./ConductorThread.module.css"; const EMPTY_BLOCKS: NormalizedBlock[] = []; -export function MsgUser({ text, queued }: { text: string; queued?: boolean }) { +export function MsgUser({ + text, + queued, + images, +}: { + text: string; + queued?: boolean; + images?: UserTurnImage[]; +}) { // A `<task-notification>` (and other CLI-injected markers) reaches us AS a user turn, // but the human didn't type it — render the clean card instead of a raw user bubble. const special = parseSpecialMessage(text); @@ -81,7 +90,20 @@ export function MsgUser({ text, queued }: { text: string; queued?: boolean }) { en attente </span> ) : null} - <UserText text={text} /> + {images && images.length ? ( + <div className="cv-user-images"> + {images.map((img, i) => ( + <img + key={i} + className="cv-user-image" + src={imageDataUrl(img)} + alt={img.name ?? "image jointe"} + title={img.name} + /> + ))} + </div> + ) : null} + {text.trim() ? <UserText text={text} /> : null} </div> </div> ); @@ -95,10 +117,13 @@ export function MsgUser({ text, queued }: { text: string; queued?: boolean }) { function InlineUserMarker({ session, turnId }: { session: string; turnId: string }) { const turn = useTurn(session, turnId); const text = turn?.streamingText ?? ""; - if (!text.trim()) return null; + const images = turn?.images; + const hasImages = !!(images && images.length); + // Nothing to show only when there is neither text nor a joined image. + if (!text.trim() && !hasImages) return null; // A CLI-injected special message (e.g. a `<task-notification>`) reaches us as a user turn // too — render its clean card, exactly like a standalone user turn (MsgUser), never raw XML. - const special = parseSpecialMessage(text); + const special = text.trim() ? parseSpecialMessage(text) : null; if (special) return <SpecialMessageCard data={special} />; // Mirror MsgUser's affordance: while the message is still QUEUED (not yet delivered to the // CLI) show "en attente", switching to "Message envoyé" once the badge clears on delivery. @@ -117,7 +142,20 @@ function InlineUserMarker({ session, turnId }: { session: string; turnId: string {queued ? "en attente" : "Message envoyé"} </span> <div className={styles.injectedMsgBody}> - <UserText text={text} /> + {hasImages ? ( + <div className="cv-user-images"> + {images.map((img, i) => ( + <img + key={i} + className="cv-user-image" + src={imageDataUrl(img)} + alt={img.name ?? "image jointe"} + title={img.name} + /> + ))} + </div> + ) : null} + {text.trim() ? <UserText text={text} /> : null} </div> </div> ); @@ -1043,6 +1081,7 @@ export function TurnRow({ }) { const turn = useTurn(session, turnId); if (!turn) return null; - if (turn.role === "user") return <MsgUser text={turn.streamingText} queued={turn.queued} />; + if (turn.role === "user") + return <MsgUser text={turn.streamingText} queued={turn.queued} images={turn.images} />; return <MsgAI session={session} turnId={turnId} busy={busy} awaiting={awaiting} />; } diff --git a/src/features/conversation/composerAttachments.test.ts b/src/features/conversation/composerAttachments.test.ts new file mode 100644 index 0000000..615897a --- /dev/null +++ b/src/features/conversation/composerAttachments.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + attachmentFromBlob, + attachmentsFor, + basename, + imageDataUrl, + MAX_ATTACH_BYTES, + normalizeWireMime, + useComposerAttachments, + wireImageMimeForPath, + type ImageAttachmentDraft, +} from "./composerAttachments"; + +describe("wireImageMimeForPath", () => { + it("maps model-attachable image extensions to their wire MIME", () => { + expect(wireImageMimeForPath("/a/b/shot.png")).toBe("image/png"); + expect(wireImageMimeForPath("photo.JPG")).toBe("image/jpeg"); + expect(wireImageMimeForPath("x.jpeg")).toBe("image/jpeg"); + expect(wireImageMimeForPath("x.jfif")).toBe("image/jpeg"); + expect(wireImageMimeForPath("anim.gif")).toBe("image/gif"); + expect(wireImageMimeForPath("pic.webp")).toBe("image/webp"); + }); + + it("returns null for non-attachable files (routed to a path mention instead)", () => { + expect(wireImageMimeForPath("notes.md")).toBeNull(); + expect(wireImageMimeForPath("main.rs")).toBeNull(); + // Image-ish but NOT accepted as an image block by the model. + expect(wireImageMimeForPath("icon.svg")).toBeNull(); + expect(wireImageMimeForPath("photo.heic")).toBeNull(); + expect(wireImageMimeForPath("Makefile")).toBeNull(); + }); +}); + +describe("normalizeWireMime", () => { + it("keeps the four supported types and normalizes aliases", () => { + expect(normalizeWireMime("image/png")).toBe("image/png"); + expect(normalizeWireMime("image/jpeg")).toBe("image/jpeg"); + expect(normalizeWireMime("image/jpg")).toBe("image/jpeg"); + expect(normalizeWireMime("image/apng")).toBe("image/png"); + expect(normalizeWireMime("IMAGE/GIF")).toBe("image/gif"); + expect(normalizeWireMime("image/webp")).toBe("image/webp"); + }); + + it("rejects unsupported types", () => { + expect(normalizeWireMime("image/svg+xml")).toBeNull(); + expect(normalizeWireMime("application/pdf")).toBeNull(); + expect(normalizeWireMime("text/plain")).toBeNull(); + }); +}); + +describe("basename", () => { + it("returns the last path segment, tolerating trailing slashes", () => { + expect(basename("/a/b/c.png")).toBe("c.png"); + expect(basename("c.png")).toBe("c.png"); + expect(basename("/a/b/")).toBe("b"); + }); +}); + +describe("imageDataUrl", () => { + it("builds a data URL from mediaType + base64", () => { + expect(imageDataUrl({ mediaType: "image/png", dataBase64: "AAAA" })).toBe( + "data:image/png;base64,AAAA", + ); + }); +}); + +describe("attachments store", () => { + const conv = "conv-1"; + const att = (id: string): ImageAttachmentDraft => ({ + id, + name: `${id}.png`, + mediaType: "image/png", + dataBase64: "AAAA", + }); + + beforeEach(() => { + useComposerAttachments.setState({ byConv: {} }); + }); + + it("adds, removes, and clears per conversation", () => { + const s = useComposerAttachments.getState(); + s.add(conv, att("a")); + s.add(conv, att("b")); + s.add("other", att("c")); + expect(attachmentsFor(conv).map((a) => a.id)).toEqual(["a", "b"]); + expect(attachmentsFor("other").map((a) => a.id)).toEqual(["c"]); + + s.remove(conv, "a"); + expect(attachmentsFor(conv).map((a) => a.id)).toEqual(["b"]); + + s.clear(conv); + expect(attachmentsFor(conv)).toEqual([]); + // Clearing one conversation must not touch another. + expect(attachmentsFor("other").map((a) => a.id)).toEqual(["c"]); + }); + + it("clearAll drops every conversation's attachments", () => { + const s = useComposerAttachments.getState(); + s.add(conv, att("a")); + s.add("other", att("c")); + s.clearAll(); + expect(attachmentsFor(conv)).toEqual([]); + expect(attachmentsFor("other")).toEqual([]); + expect(useComposerAttachments.getState().byConv).toEqual({}); + }); + + it("returns a stable empty array for an unknown conversation", () => { + expect(attachmentsFor("nope")).toEqual([]); + }); +}); + +describe("attachmentFromBlob", () => { + it("returns null for an unsupported blob type", async () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/svg+xml" }); + expect(await attachmentFromBlob(blob, "x.svg")).toBeNull(); + }); + + it("rejects a blob over the size ceiling without reading it", async () => { + // A stub blob past the cap — attachmentFromBlob must bail on size before FileReader. + const huge = { type: "image/png", size: MAX_ATTACH_BYTES + 1 } as unknown as Blob; + const res = await attachmentFromBlob(huge, "big.png"); + expect(res && "error" in res ? res.error : null).toMatch(/trop volumineuse/); + }); + + it("reads a small supported image into a wire-ready base64 draft", async () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }); + const res = await attachmentFromBlob(blob, "small.png"); + expect(res && !("error" in res) ? res.mediaType : null).toBe("image/png"); + expect(res && !("error" in res) ? res.dataBase64.length > 0 : false).toBe(true); + }); +}); diff --git a/src/features/conversation/composerAttachments.ts b/src/features/conversation/composerAttachments.ts new file mode 100644 index 0000000..7edaba4 --- /dev/null +++ b/src/features/conversation/composerAttachments.ts @@ -0,0 +1,175 @@ +// Composer image attachments — the "+" button and paste-an-image flows. +// +// State is IN-MEMORY and per-conversation (keyed by the stable conv id), NOT +// persisted: base64 image blobs would bloat localStorage, and an attachment is a +// transient part of the message being composed. It survives a conversation switch +// (the pane remounts, but the store doesn't) and is cleared on send. The typed text +// draft lives separately in `composerDrafts` (that one IS persisted). +// +// Only the four media types the model accepts as `image` blocks are attachable +// (png / jpeg / gif / webp — verified against the `claude` binary). Any other file +// picked via "+" is inserted as a path mention in the text instead (Claude reads it +// with its own tools); see ConductorComposer. + +import { create } from "zustand"; +import { commands } from "../../ipc/client"; +import type { UserTurnImage } from "../../store/types"; + +/** An attachment in the composer: a `UserTurnImage` plus a local id for list keys + * and removal. `dataBase64` is raw base64 (no `data:` prefix), wire-ready. */ +export interface ImageAttachmentDraft extends UserTurnImage { + id: string; +} + +interface AttachmentsState { + /** Attachments per conversation stable id. */ + byConv: Record<string, ImageAttachmentDraft[]>; + add: (convId: string, att: ImageAttachmentDraft) => void; + remove: (convId: string, id: string) => void; + clear: (convId: string) => void; + /** Drop every conversation's attachments — for a full data wipe. */ + clearAll: () => void; +} + +export const useComposerAttachments = create<AttachmentsState>((set) => ({ + byConv: {}, + add: (convId, att) => + set((s) => ({ byConv: { ...s.byConv, [convId]: [...(s.byConv[convId] ?? []), att] } })), + remove: (convId, id) => + set((s) => ({ + byConv: { ...s.byConv, [convId]: (s.byConv[convId] ?? []).filter((a) => a.id !== id) }, + })), + clear: (convId) => + set((s) => { + if (!s.byConv[convId]?.length) return s; + const next = { ...s.byConv }; + delete next[convId]; + return { byConv: next }; + }), + clearAll: () => set((s) => (Object.keys(s.byConv).length ? { byConv: {} } : s)), +})); + +/** Forget one conversation's attachments — call when it's deleted so the in-memory + * map doesn't accumulate orphan base64 blobs. Mirrors clearComposerDraft. */ +export function clearComposerAttachments(convId: string): void { + useComposerAttachments.getState().clear(convId); +} + +/** Drop every conversation's attachments — call on a full data wipe ("Tout + * supprimer"). Mirrors clearAllComposerDrafts. */ +export function clearAllComposerAttachments(): void { + useComposerAttachments.getState().clearAll(); +} + +const EMPTY: ImageAttachmentDraft[] = []; + +/** Reactive selector: this conversation's current attachments (stable empty array). */ +export function useConvAttachments(convId: string): ImageAttachmentDraft[] { + return useComposerAttachments((s) => s.byConv[convId] ?? EMPTY); +} + +/** Imperative read (for the send path). */ +export function attachmentsFor(convId: string): ImageAttachmentDraft[] { + return useComposerAttachments.getState().byConv[convId] ?? EMPTY; +} + +// ---- media-type gating ------------------------------------------------------ + +// Extension → wire media type, restricted to what the model accepts as an image +// block. A picked file whose extension isn't here is NOT an attachable image. +const EXT_WIRE_MIME: Record<string, string> = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + jfif: "image/jpeg", + gif: "image/gif", + webp: "image/webp", +}; + +const SUPPORTED_WIRE_MIME = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + +/** The wire media type for a path if it is a model-attachable image, else null. */ +export function wireImageMimeForPath(path: string): string | null { + const name = path.toLowerCase(); + const dot = name.lastIndexOf("."); + const ext = dot >= 0 ? name.slice(dot + 1) : ""; + return EXT_WIRE_MIME[ext] ?? null; +} + +/** Normalize a browser blob MIME (paste/drop) to a supported wire type, or null. */ +export function normalizeWireMime(mime: string): string | null { + const m = mime.toLowerCase(); + if (m === "image/jpg") return "image/jpeg"; + if (m === "image/apng") return "image/png"; + return SUPPORTED_WIRE_MIME.has(m) ? m : null; +} + +// ---- building attachments --------------------------------------------------- + +/** The last path segment of a POSIX-ish path. */ +export function basename(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + const i = trimmed.lastIndexOf("/"); + return i >= 0 ? trimmed.slice(i + 1) : trimmed; +} + +const uid = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `att_${Math.abs(hashString(String(performance.now())))}`; + +// Deterministic fallback id (no Math.random) for environments without crypto. +function hashString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i); + return h; +} + +/** Outcome of trying to attach a picked file path. `null` = not an attachable image + * (caller should treat it as a file mention). `{ error }` = a real, surfaceable + * failure (unreadable / too large). */ +export type PathAttachResult = ImageAttachmentDraft | { error: string } | null; + +/** Read a picked image file's bytes (base64) via the fs service. Returns null when + * the path isn't a model-attachable image, so the caller inserts a path mention. */ +export async function attachmentFromPath(path: string): Promise<PathAttachResult> { + const mediaType = wireImageMimeForPath(path); + if (!mediaType) return null; + const res = await commands.readImage(path); + if (res.status === "error") return { error: `Lecture de l'image échouée : ${res.error}` }; + if (res.data.too_large) return { error: `Image trop volumineuse : ${basename(path)}` }; + return { id: uid(), name: basename(path), mediaType, dataBase64: res.data.data_base64 }; +} + +// Byte ceiling for an attached image, mirroring the fs service's MAX_FILE_BYTES +// (src-tauri/src/fs/mod.rs) so BOTH the file-picker path (guarded by read_image's +// too_large) and the paste path enforce the same limit — a full-res base64 blob on +// the wire + in memory would otherwise stall the webview. +export const MAX_ATTACH_BYTES = 16 * 1024 * 1024; + +/** Turn a pasted/dropped image blob into an attachment (FileReader → base64). Returns + * null when the blob isn't a supported image type, or `{ error }` when it exceeds + * MAX_ATTACH_BYTES (the paste path has no fs-layer size guard, unlike the picker). */ +export function attachmentFromBlob(blob: Blob, name: string): Promise<PathAttachResult> { + const mediaType = normalizeWireMime(blob.type); + if (!mediaType) return Promise.resolve(null); + if (blob.size > MAX_ATTACH_BYTES) { + const mib = Math.round(MAX_ATTACH_BYTES / (1024 * 1024)); + return Promise.resolve({ error: `Image trop volumineuse (max ${mib} Mio) : ${name}` }); + } + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const url = typeof reader.result === "string" ? reader.result : ""; + const comma = url.indexOf(","); + resolve(comma >= 0 ? { id: uid(), name, mediaType, dataBase64: url.slice(comma + 1) } : null); + }; + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); +} + +/** A `data:` URL for rendering an attachment/turn image as a thumbnail. */ +export function imageDataUrl(img: { mediaType: string; dataBase64: string }): string { + return `data:${img.mediaType};base64,${img.dataBase64}`; +} diff --git a/src/ipc/bindings.ts b/src/ipc/bindings.ts index 00d3df2..f445c2f 100644 --- a/src/ipc/bindings.ts +++ b/src/ipc/bindings.ts @@ -195,11 +195,13 @@ async getPlanUsage() : Promise<Result<PlanUsage, UsageError>> { } }, /** - * Send a user turn to a session. + * Send a user turn to a session: the typed `text` plus any joined `images` (sent as + * `image` blocks in the message `content` array). `images` is empty for a plain text + * turn. */ -async sendMessage(session: string, text: string) : Promise<Result<null, string>> { +async sendMessage(session: string, text: string, images: ImageAttachment[]) : Promise<Result<null, string>> { try { - return { status: "ok", data: await TAURI_INVOKE("send_message", { session, text }) }; + return { status: "ok", data: await TAURI_INVOKE("send_message", { session, text, images }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -1570,6 +1572,22 @@ unborn: boolean; * Changed entries (staged + unstaged + untracked), in git's order. */ files: GitFileEntry[] } +/** + * An image joined to a user turn: base64 bytes + their MIME type. Sent inside the + * message `content` array as an `image` block (spec §3.10) — verified accepted by + * `claude` 2.1.187, which "sees" it and answers about its content. The `data` field + * is raw base64 (NO `data:` URL prefix). Also an IPC command param (`send_message`), + * so it derives `specta::Type` for the generated TS bindings. + */ +export type ImageAttachment = { +/** + * MIME type, e.g. `image/png`, `image/jpeg`, `image/gif`, `image/webp`. + */ +media_type: string; +/** + * Base64-encoded image bytes, with NO `data:image/...;base64,` prefix. + */ +data: string } /** * An image file's bytes, base64-encoded for the webview to render as a `data:` * URL. Unlike [`read_file`], the binary content IS the payload here — images are diff --git a/src/ipc/mock/mockBindings.ts b/src/ipc/mock/mockBindings.ts index 0411948..7340cb3 100644 --- a/src/ipc/mock/mockBindings.ts +++ b/src/ipc/mock/mockBindings.ts @@ -209,7 +209,11 @@ export const mockCommands = { return ok(session); }, - async sendMessage(session: string, _text: string): Promise<Result<null, string>> { + async sendMessage( + session: string, + _text: string, + _images?: { media_type: string; data: string }[], + ): Promise<Result<null, string>> { const demo = typeof location !== "undefined" ? new URLSearchParams(location.search).get("demo") diff --git a/src/ipc/useCommands.ts b/src/ipc/useCommands.ts index 3102c4b..4e6fa9f 100644 --- a/src/ipc/useCommands.ts +++ b/src/ipc/useCommands.ts @@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { commands } from "./client"; import type { PermissionDecision, Result } from "./client"; import { useConversationStore } from "../store/conversationStore"; +import type { UserTurnImage } from "../store/types"; import { ensureConversationSession, liveHandle, @@ -38,11 +39,22 @@ export function useSendMessage(convId: string) { // `worktree` (first send only): spawn this conversation inside a freshly // created git worktree instead of the repo's main checkout. `queued`: the // agent was busy at send time, so the CLI will inject this mid-turn — flag the - // optimistic turn so the UI shows it as "en attente". - mutationFn: async ({ text, worktree, queued }: { text: string; worktree?: boolean; queued?: boolean }) => { + // optimistic turn so the UI shows it as "en attente". `images`: files joined via + // the composer's "+" / paste, sent as `image` blocks and shown as thumbnails. + mutationFn: async ({ + text, + worktree, + queued, + images, + }: { + text: string; + worktree?: boolean; + queued?: boolean; + images?: UserTurnImage[]; + }) => { // The core does not echo user turns, so append optimistically (keyed by the // stable id) before sending — instant even while the session spawns. - addUserTurn(convId, text, queued); + addUserTurn(convId, text, queued, images); // Sending the next message consumes any pending reminder: the user has moved // on from the previous result. `addUserTurn` clears the LIVE turnSeen; clear // the PERSISTED reminder too so it doesn't re-surface on the next restart. @@ -53,7 +65,10 @@ export function useSendMessage(convId: string) { // Lazy spawn: a conversation has no live process until its first message // (or its first message after being stopped/ended). const handle = await ensureConversationSession(convId, { worktree }); - const res = await unwrap(commands.sendMessage(handle, text)); + // Map the UI's image shape to the wire's `ImageAttachment` (media_type + raw + // base64). Empty array for a plain text turn. + const wireImages = (images ?? []).map((i) => ({ media_type: i.mediaType, data: i.dataBase64 })); + const res = await unwrap(commands.sendMessage(handle, text, wireImages)); // On each of the first few messages of a still-untitled conversation, ask the // binary to (re)generate a smart title from the accumulated intent (capped, then // frozen; no-op once renamed / over the cap / no live session). Like the VS Code diff --git a/src/store/conversationStore.ts b/src/store/conversationStore.ts index 3577745..3e0c00c 100644 --- a/src/store/conversationStore.ts +++ b/src/store/conversationStore.ts @@ -37,6 +37,7 @@ import type { ToolResult, Turn, TurnResultMeta, + UserTurnImage, } from "./types"; import { isBackgroundAgentInput, isDetachedAgentAck } from "../agent/subagentMeta"; import { latestTodosInBlocks, todoSummary } from "./todos"; @@ -151,7 +152,7 @@ interface ConversationState { appendThinking: (session: string, messageId: string, text: string) => void; /** Append an optimistic user turn. `queued` marks it as sent mid-turn (the CLI * injects it before the loop ends) → drives the "en attente" badge. */ - addUserTurn: (session: string, text: string, queued?: boolean) => void; + addUserTurn: (session: string, text: string, queued?: boolean, images?: UserTurnImage[]) => void; /** Append a visible error bubble to the timeline (e.g. a send that failed to * spawn the session). Makes an otherwise-silent command failure self-evident. * `detail` (optional) is the raw technical payload, shown behind a disclosure. */ @@ -293,7 +294,7 @@ export const useConversationStore = create<ConversationState>((set) => { appendThinking: (session, messageId, text) => appendBuffer(session, messageId, "streamingThinking", text), - addUserTurn: (session, text, queued) => + addUserTurn: (session, text, queued, images) => withEntry(session, (entry) => { const id = `user_${entry.seq}`; const turn: Turn = { @@ -306,6 +307,7 @@ export const useConversationStore = create<ConversationState>((set) => { parentToolUseId: null, hasThinking: false, queued, + images: images && images.length ? images : undefined, // Durable twin of `queued` for clean-output grouping: never cleared, so a mid-work // injection stays distinguishable from a fresh prompt long after the badge clears. injectedMidTurn: queued, diff --git a/src/store/conversationsStore.ts b/src/store/conversationsStore.ts index d2a612d..a9c37ed 100644 --- a/src/store/conversationsStore.ts +++ b/src/store/conversationsStore.ts @@ -32,6 +32,10 @@ import { useAppErrors } from "./appErrors"; import { getCachedWindow, clearCachedWindow, clearAllCachedWindows } from "./contextWindowCache"; import { clearTodoBarOpen, clearAllTodoBarOpen } from "./todoBarUi"; import { clearComposerDraft, clearAllComposerDrafts } from "./composerDrafts"; +import { + clearComposerAttachments, + clearAllComposerAttachments, +} from "../features/conversation/composerAttachments"; import { clearWorkFold, clearAllWorkFold } from "./workFold"; import { clearPlanAnnotations, @@ -400,6 +404,7 @@ export const useConversationsStore = create<ConversationsState>()((set, get) => disposeTerminal(c.id); clearTodoBarOpen(c.id); clearComposerDraft(c.id); + clearComposerAttachments(c.id); clearWorkFold(c.id); clearPlanAnnotations(c.id); useGitViewStore.getState().clear(c.id); @@ -468,6 +473,7 @@ export const useConversationsStore = create<ConversationsState>()((set, get) => clearCachedWindow(id); clearTodoBarOpen(id); clearComposerDraft(id); + clearComposerAttachments(id); clearWorkFold(id); clearPlanAnnotations(id); autoTitlePending.delete(id); @@ -1196,6 +1202,7 @@ export async function wipeAllData(): Promise<void> { clearAllCachedWindows(); clearAllTodoBarOpen(); clearAllComposerDrafts(); + clearAllComposerAttachments(); clearAllWorkFold(); clearAllPlanAnnotations(); clearAllSidebarFold(); diff --git a/src/store/types.ts b/src/store/types.ts index 9cfa4a2..2c09136 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -60,10 +60,24 @@ export type TurnRole = "assistant" | "user"; * block currently being typed. Render = finalized `blocks` followed by the live * buffer tail, so nothing already shown is ever overwritten. */ +/** An image joined to a user turn, kept for the optimistic bubble (thumbnail) and + * ready for the wire (base64 → `image` block). `dataBase64` is raw base64, NO + * `data:` prefix; the renderer builds the data URL from `mediaType` + `dataBase64`. */ +export interface UserTurnImage { + mediaType: string; + dataBase64: string; + /** Display name (filename, or "Image collée") — optional, for the chip/title. */ + name?: string; +} + export interface Turn { id: string; role: TurnRole; status: TurnStatus; + /** User turn only: images joined to this message (attached via the composer's + * "+" or pasted). Rendered as thumbnails in the bubble; absent on turns hydrated + * from disk (the optimistic-only path carries them). */ + images?: UserTurnImage[]; /** Live text of the block currently streaming (ignored once status!=="streaming"). */ streamingText: string; /** Live extended-thinking of the block currently streaming. */ diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index 113e6d4..94b902f 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -445,10 +445,25 @@ .cv-slash-foot { display:flex; align-items:baseline; gap:8px; padding:7px 11px; border-top:1px solid var(--wf-line-2); background:var(--wf-panel); } .cv-slash-foot-cmd { font-size:11px; color:var(--wf-tx); white-space:nowrap; flex:0 0 auto; } .cv-slash-foot-desc { font-size:11px; color:var(--wf-tx-lo); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } -.cv-add { width:28px; height:28px; flex:0 0 auto; border:0; border-radius:7px; background:transparent; color:var(--wf-tx-lo); display:inline-flex; align-items:center; justify-content:center; cursor:default; } +.cv-add { width:28px; height:28px; flex:0 0 auto; border:0; border-radius:7px; background:transparent; color:var(--wf-tx-lo); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; transition:background .12s ease, color .12s ease; } .cv-add:hover { background:var(--wf-panel-3); color:var(--wf-tx-hi); } .cv-ph { flex:1; font-size:12.5px; color:var(--wf-tx-lo); } .cv-send { width:30px; height:30px; flex:0 0 auto; border:0; border-radius:8px; background:var(--wf-accent); color:#1a0f0a; display:inline-flex; align-items:center; justify-content:center; cursor:default; } + +/* Attachments: model-attachable images joined to the next message, shown as chips + above the text row (removable), then as thumbnails in the sent user bubble. */ +.cv-attach-row { display:flex; flex-wrap:wrap; align-items:center; gap:8px; padding:6px 8px 2px; } +.cv-attach { display:inline-flex; align-items:center; gap:7px; max-width:220px; padding:4px 4px 4px 5px; background:var(--wf-panel-2); border:1px solid var(--wf-line); border-radius:9px; } +.cv-attach-thumb { width:30px; height:30px; flex:0 0 auto; object-fit:cover; border-radius:6px; background:var(--wf-panel-3); } +.cv-attach-name { font-size:11.5px; color:var(--wf-tx); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.cv-attach-x { width:20px; height:20px; flex:0 0 auto; border:0; background:transparent; color:var(--wf-tx-lo); display:inline-flex; align-items:center; justify-content:center; border-radius:5px; cursor:pointer; transition:background .12s ease, color .12s ease; } +.cv-attach-x:hover { background:var(--wf-panel-3); color:var(--wf-tx-hi); } +.cv-attach-x .wf-ico { width:13px; height:13px; } +.cv-attach-err { font-size:11px; color:var(--wf-err); } + +/* Joined images shown inside a sent user bubble. */ +.cv-user-images { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; } +.cv-user-image { max-width:220px; max-height:180px; border-radius:8px; border:1px solid var(--wf-line); object-fit:contain; background:var(--wf-panel-3); } /* Controls row sits at the bottom of the same card, just below the text row. */ .cv-comp-foot { display:flex; align-items:center; gap:3px; padding:5px 6px 2px; font-size:11px; color:var(--wf-tx-lo); } .cv-foot-sep { width:1px; height:16px; background:var(--wf-line); margin:0 5px; } From 73c6669c02fafe78def8a12ba82b33a4b242d402 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 13:21:44 +0200 Subject: [PATCH 12/91] =?UTF-8?q?feat(fleet+settings):=20fleet=20readout,?= =?UTF-8?q?=20alerte=20termin=C3=A9+t=C3=A2che=20de=20fond,=20masquage=20t?= =?UTF-8?q?ask-notifications,=20refonte=20R=C3=A9glages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front-only, 4 features : 1. Fleet readout — ligne de statut adaptative des compteurs d'agents par stage (Running · Review · Need Attention · Idle). Bandeau large en haut du Flight Deck (remplace l'AttentionBar) + encadré compact en bas de la sidebar (3 stages fusionnés : Attention = Review + Need Attention). Zéros masqués, activité d'abord, « Fleet rests » au repos. 2 toggles séparés (display store). Nouveau tally tallyFleet/useFleetCounts + fleetSegments/mergedFleetSegments dans agent/fleet.ts ; readoutBucket dans status.ts. 2. Alerte « terminé + tâche de fond » — quand un tour finit alors qu'une tâche de fond tourne, accent violet sur le review/question (anneau .wf-dot.bgring + chip « N en fond ») pour le distinguer d'un vrai fini. Réglage alertOnBackgroundWait (défaut ON) : OFF → passe direct en backgrounding, sans alerte ni notif OS (gate notifyTransition sur le statut résultant backgrounding). bg? porté par review/needInput/error ; backgroundCount(). L'agrégat flotte reçoit désormais les vraies tâches de fond par conv (runningCountsByConv). 3. Masquage des <task-notification> — bruit au reload/import historique. Gate unique dans SpecialMessageCard (pref showTaskNotifications, défaut OFF) ; code de rendu conservé. 4. Refonte de la page Réglages — onglets à accent coral, groupes en cartes arrondies, en-têtes de page, hero « À propos ». Kit réutilisable SettingsKit (PageHead/SettingsGroup/ ToggleRow). CSS mort nettoyé. tsc 0 · vitest 674/674. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/agent/fleet.test.ts | 97 ++++++- src/agent/fleet.ts | 129 ++++++--- src/agent/status.test.ts | 79 +++++ src/agent/status.ts | 82 +++++- src/agent/useAgentStatus.ts | 28 +- .../conversation/ConductorSidebar.tsx | 11 +- .../conversation/SpecialMessageCard.tsx | 17 +- src/features/flightdeck/AttentionBar.tsx | 41 --- src/features/flightdeck/FlightDeck.tsx | 10 +- src/features/flightdeck/StateBlock.tsx | 24 +- src/features/flightdeck/StreamCard.tsx | 4 +- src/features/settings/ConversationSection.tsx | 16 +- .../settings/NotificationsSection.tsx | 41 +-- src/features/settings/SettingsKit.tsx | 79 +++++ .../settings/SettingsPanel.module.css | 273 +++++++++++++----- src/features/settings/SettingsPanel.tsx | 141 +++++++-- src/features/settings/ShortcutsSection.tsx | 10 +- src/features/settings/UpdateSection.tsx | 3 +- src/ipc/useGlobalSessionEvents.ts | 25 +- src/main.tsx | 1 + src/store/backgroundTasksStore.ts | 20 ++ src/store/display.ts | 33 +++ src/ui/FleetReadout.tsx | 82 ++++++ src/ui/conductor-fleet.css | 109 +++++++ src/ui/conductor-flightdeck.css | 8 +- src/ui/conductor-wirekit.css | 21 ++ src/ui/kit.tsx | 7 +- 27 files changed, 1120 insertions(+), 271 deletions(-) delete mode 100644 src/features/flightdeck/AttentionBar.tsx create mode 100644 src/features/settings/SettingsKit.tsx create mode 100644 src/ui/FleetReadout.tsx create mode 100644 src/ui/conductor-fleet.css diff --git a/src/agent/fleet.test.ts b/src/agent/fleet.test.ts index edc0541..e6a1087 100644 --- a/src/agent/fleet.test.ts +++ b/src/agent/fleet.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "vitest"; -import { lanesToTokens, orderLanes, rebuildLanes, tallyAttention } from "./fleet"; +import { + fleetSegments, + isFleetCalm, + lanesToTokens, + mergedFleetSegments, + orderLanes, + rebuildLanes, + tallyFleet, +} from "./fleet"; import { statusRank, type AgentStatus } from "./status"; import type { Conversation, Repo } from "../store/conversationsStore"; @@ -10,28 +18,87 @@ function conv(id: string, repoId: string, lastActivityAt: number): Conversation return { id, repoId, lastActivityAt } as unknown as Conversation; } -describe("tallyAttention", () => { - it("buckets the attention states and ignores quiet ones (running/idle/off)", () => { +describe("tallyFleet", () => { + it("folds every status kind into exactly one of the four readout stages", () => { const statuses: AgentStatus[] = [ - { kind: "needIntervention", tool: "Bash" }, // input - { kind: "needInput", via: "questionnaire", prompt: "?" }, // input - { kind: "needInput", via: "openQuestion", prompt: "?" }, // input + { kind: "running", activity: null }, // running + { kind: "backgrounding", count: 2 }, // running (background work still counts) { kind: "review" }, // review - { kind: "error", message: "boom" }, // error - { kind: "running", activity: null }, // ignored - { kind: "idle" }, // ignored - { kind: "off" }, // ignored + { kind: "needIntervention", tool: "Bash" }, // needAttention + { kind: "needInput", via: "questionnaire", prompt: "?" }, // needAttention + { kind: "needInput", via: "openQuestion", prompt: "?" }, // needAttention + { kind: "error", message: "boom" }, // needAttention + { kind: "idle" }, // idle + { kind: "off" }, // idle (dormant, no process) ]; - expect(tallyAttention(statuses)).toEqual({ - needsInput: 3, + expect(tallyFleet(statuses)).toEqual({ + running: 2, review: 1, - error: 1, - total: 5, + needAttention: 4, + idle: 2, + total: 9, }); }); + it("keeps running + review + needAttention + idle === total", () => { + const t = tallyFleet([ + { kind: "running", activity: null }, + { kind: "review" }, + { kind: "error", message: "x" }, + { kind: "off" }, + ]); + expect(t.running + t.review + t.needAttention + t.idle).toBe(t.total); + }); + it("is all-zero for an empty fleet", () => { - expect(tallyAttention([])).toEqual({ needsInput: 0, review: 0, error: 0, total: 0 }); + expect(tallyFleet([])).toEqual({ + running: 0, + review: 0, + needAttention: 0, + idle: 0, + total: 0, + }); + }); +}); + +describe("fleetSegments", () => { + it("keeps only non-zero stages, in activity-first order", () => { + const segs = fleetSegments({ running: 3, review: 0, needAttention: 2, idle: 5, total: 10 }); + expect(segs.map((s) => [s.key, s.count])).toEqual([ + ["running", 3], + ["needAttention", 2], // review dropped (zero); order still Running → NeedAttention → Idle + ["idle", 5], + ]); + expect(segs.find((s) => s.key === "needAttention")!.label).toBe("Need Attention"); + }); + + it("returns nothing when every stage is zero", () => { + expect(fleetSegments({ running: 0, review: 0, needAttention: 0, idle: 0, total: 0 })).toEqual([]); + }); +}); + +describe("mergedFleetSegments (compact sidebar view)", () => { + it("folds review into a single 'Attention' bucket, keeping the orange key", () => { + const segs = mergedFleetSegments({ running: 3, review: 1, needAttention: 2, idle: 5, total: 11 }); + expect(segs.map((s) => [s.key, s.label, s.count])).toEqual([ + ["running", "Running", 3], + ["needAttention", "Attention", 3], // review (1) + needAttention (2) + ["idle", "Idle", 5], + ]); + }); + + it("drops the Attention stage when both review and needAttention are zero", () => { + const segs = mergedFleetSegments({ running: 3, review: 0, needAttention: 0, idle: 4, total: 7 }); + expect(segs.map((s) => s.key)).toEqual(["running", "idle"]); + }); +}); + +describe("isFleetCalm", () => { + it("is calm only when nothing is running, in review, or needing attention", () => { + expect(isFleetCalm({ running: 0, review: 0, needAttention: 0, idle: 7, total: 7 })).toBe(true); + expect(isFleetCalm({ running: 1, review: 0, needAttention: 0, idle: 6, total: 7 })).toBe(false); + expect(isFleetCalm({ running: 0, review: 1, needAttention: 0, idle: 6, total: 7 })).toBe(false); + expect(isFleetCalm({ running: 0, review: 0, needAttention: 1, idle: 6, total: 7 })).toBe(false); }); }); diff --git a/src/agent/fleet.ts b/src/agent/fleet.ts index ad33d93..e1ff0b6 100644 --- a/src/agent/fleet.ts +++ b/src/agent/fleet.ts @@ -1,7 +1,8 @@ -// Fleet-level aggregation of agent statuses — the counts behind the FlightDeck -// AttentionBar and the "Agents" nav badge. Each conversation's status is derived -// from the SAME pure model the per-card hook uses (agentStatusForEntry), so the bar -// and the cards never disagree. +// Fleet-level aggregation of agent statuses — the counts behind the "Fleet readout" +// banner (the compact box at the bottom of the conversation sidebar and the wide bar +// at the top of the FlightDeck) and the status-ordered FlightDeck lanes. Each +// conversation's status is derived from the SAME pure model the per-card hook uses +// (agentStatusForEntry), so the banner and the cards never disagree. // // Perf note: this recomputes over all sessions on every message-store delta. Fine // at the handful-of-agents scale we target; if the fleet grows large, promote a @@ -9,6 +10,8 @@ import { useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { useConversationStore } from "../store/conversationStore"; +import { useRunningCountsByConv } from "../store/backgroundTasksStore"; +import { useDisplay } from "../store/display"; import { groupByRepo, useConversations, @@ -17,57 +20,103 @@ import { type Repo, type RepoGroup, } from "../store/conversationsStore"; -import { rowAttention, statusRank, type AgentStatus } from "./status"; +import { readoutBucket, statusRank, type AgentStatus } from "./status"; import { agentStatusForEntry } from "./useAgentStatus"; -export interface FleetAttention { - /** Streams blocked on the user (permission / questionnaire / open question). */ - needsInput: number; - /** Streams that finished cleanly and await a look. */ +// ---- Fleet readout (the "N Running · N Review · …" banner) ---------------------- + +/** + * The four fleet-readout stage counts, plus the fleet total. Coarser than + * {@link FleetAttention}: it also carries `running` and `idle`, so the readout banner + * (sidebar + FlightDeck top) can render every stage, not just the ones demanding + * attention. Each conversation lands in exactly one bucket via {@link readoutBucket}, + * so `running + review + needAttention + idle === total`. + */ +export interface FleetCounts { + running: number; review: number; - /** Streams whose last turn errored. */ - error: number; - /** needsInput + review + error — the total demanding attention. */ + needAttention: number; + idle: number; total: number; } -const EMPTY: FleetAttention = { needsInput: 0, review: 0, error: 0, total: 0 }; +const EMPTY_COUNTS: FleetCounts = { running: 0, review: 0, needAttention: 0, idle: 0, total: 0 }; -/** Tally a list of statuses into the attention buckets. Pure + testable. */ -export function tallyAttention(statuses: AgentStatus[]): FleetAttention { - const t: FleetAttention = { needsInput: 0, review: 0, error: 0, total: 0 }; +/** Tally a list of statuses into the four readout stages. Pure + testable. */ +export function tallyFleet(statuses: AgentStatus[]): FleetCounts { + const t: FleetCounts = { running: 0, review: 0, needAttention: 0, idle: 0, total: 0 }; for (const s of statuses) { - switch (rowAttention(s)) { - case "input": - t.needsInput++; - t.total++; - break; - case "review": - t.review++; - t.total++; - break; - case "error": - t.error++; - t.total++; - break; - } + t[readoutBucket(s)]++; + t.total++; } return t; } +/** A single rendered stage of the readout line: its stage key (→ colour class), the + * English label, and the count. */ +export interface FleetSegment { + key: "running" | "review" | "needAttention" | "idle"; + label: string; + count: number; +} + +/** + * The stages to render, in display order (activity first: Running → Review → Need + * Attention → Idle), with the zero stages dropped. Pure so the "hide zeros / order" + * rule is locked in a test. Idle is included like any other stage when non-zero — the + * calm phrasing ("Fleet rests · N Idle") is a separate presentation decision the + * component makes via {@link isFleetCalm}, not a filtering rule here. + */ +export function fleetSegments(c: FleetCounts): FleetSegment[] { + const all: FleetSegment[] = [ + { key: "running", label: "Running", count: c.running }, + { key: "review", label: "Review", count: c.review }, + { key: "needAttention", label: "Need Attention", count: c.needAttention }, + { key: "idle", label: "Idle", count: c.idle }, + ]; + return all.filter((s) => s.count > 0); +} + +/** + * The COMPACT stage list for the narrow conversation sidebar: only three stages, with + * Review folded into a single "Attention" bucket (review + needAttention) so the full + * words fit on one line even at the minimum sidebar width. Same zero-drop + activity- + * first order as {@link fleetSegments}. The merged bucket keeps the `needAttention` key + * so it renders in the same (orange) colour. The FlightDeck keeps the full four-stage + * {@link fleetSegments}; only the sidebar merges. + */ +export function mergedFleetSegments(c: FleetCounts): FleetSegment[] { + const all: FleetSegment[] = [ + { key: "running", label: "Running", count: c.running }, + { key: "needAttention", label: "Attention", count: c.review + c.needAttention }, + { key: "idle", label: "Idle", count: c.idle }, + ]; + return all.filter((s) => s.count > 0); +} + +/** Whether nothing in the fleet is active — no running, review, or attention stage. + * In this state the banner reads "Fleet rests · N Idle" instead of listing stages. */ +export function isFleetCalm(c: FleetCounts): boolean { + return c.running === 0 && c.review === 0 && c.needAttention === 0; +} + /** - * Live attention tally across every conversation. `useShallow` over the computed - * counts means the bar/badge only re-render when a count actually moves, not on - * every streaming delta. + * Live readout counts across every conversation — the same derive-per-conversation + * path as {@link useFleetAttention}, tallied into the four stages instead. `useShallow` + * over the counts means the banner only re-renders when a count actually moves. */ -export function useFleetAttention(): FleetAttention { +export function useFleetCounts(): FleetCounts { const convs = useConversations(); + const bg = useRunningCountsByConv(); + const alert = useDisplay((s) => s.alertOnBackgroundWait); return useConversationStore( useShallow((s) => convs.length === 0 - ? EMPTY - : tallyAttention( - convs.map((c) => agentStatusForEntry(c.handle, s.sessions[c.id], c.pendingReminder)), + ? EMPTY_COUNTS + : tallyFleet( + convs.map((c) => + agentStatusForEntry(c.handle, s.sessions[c.id], c.pendingReminder, bg[c.id] ?? 0, alert), + ), ), ), ); @@ -155,10 +204,14 @@ export function rebuildLanes(tokens: string[], repos: Repo[], conversations: Con export function useFleetLanes(): FleetLane[] { const repos = useRepos(); const conversations = useConversations(); + const bg = useRunningCountsByConv(); + const alert = useDisplay((s) => s.alertOnBackgroundWait); const tokens = useConversationStore( useShallow((s) => { const rank = (c: Conversation) => - statusRank(agentStatusForEntry(c.handle, s.sessions[c.id], c.pendingReminder)); + statusRank( + agentStatusForEntry(c.handle, s.sessions[c.id], c.pendingReminder, bg[c.id] ?? 0, alert), + ); return lanesToTokens(orderLanes(repos, conversations, rank)); }), ); diff --git a/src/agent/status.test.ts b/src/agent/status.test.ts index 11415f4..8cf5af6 100644 --- a/src/agent/status.test.ts +++ b/src/agent/status.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from "vitest"; import { agentStatusToDot, + backgroundCount, deriveAgentStatus, isDismissable, looksLikeQuestion, + readoutBucket, rowAttention, statusReminderKind, type AgentSignals, @@ -24,6 +26,7 @@ function sig(over: Partial<AgentSignals> = {}): AgentSignals { lastAssistantText: null, persistedReminder: null, runningBackgroundTasks: 0, + alertWhileBackgrounding: true, ...over, }; } @@ -167,6 +170,61 @@ describe("deriveAgentStatus", () => { ).kind, ).toBe("error"); }); + + describe("background accent + the alert-while-backgrounding preference", () => { + it("a clean finish with bg running carries the bg count (alert on = default)", () => { + const s = deriveAgentStatus( + sig({ turnSeen: false, lastTurnSubtype: "success", runningBackgroundTasks: 2 }), + ); + expect(s).toEqual({ kind: "review", bg: 2 }); + expect(backgroundCount(s)).toBe(2); + }); + + it("with the alert OFF, a clean finish + bg goes straight to backgrounding (no review)", () => { + const s = deriveAgentStatus( + sig({ + turnSeen: false, + lastTurnSubtype: "success", + runningBackgroundTasks: 2, + alertWhileBackgrounding: false, + }), + ); + expect(s).toEqual({ kind: "backgrounding", count: 2 }); + }); + + it("a QUESTION with bg still alerts even when the setting is off (carries bg)", () => { + const s = deriveAgentStatus( + sig({ + turnSeen: false, + lastAssistantText: "On y va ?", + runningBackgroundTasks: 1, + alertWhileBackgrounding: false, + }), + ); + expect(s).toEqual({ kind: "needInput", via: "openQuestion", prompt: "On y va ?", bg: 1 }); + }); + + it("an ERROR with bg still alerts even when the setting is off (carries bg)", () => { + const s = deriveAgentStatus( + sig({ + turnSeen: false, + lastTurnIsError: true, + runningBackgroundTasks: 1, + alertWhileBackgrounding: false, + }), + ); + expect(s.kind).toBe("error"); + expect(backgroundCount(s)).toBe(1); + }); + + it("with no bg running, the setting is a no-op (plain review, backgroundCount 0)", () => { + const s = deriveAgentStatus( + sig({ turnSeen: false, lastTurnSubtype: "success", alertWhileBackgrounding: false }), + ); + expect(s).toEqual({ kind: "review" }); + expect(backgroundCount(s)).toBe(0); + }); + }); }); it("once consumed (turnSeen=true) a finished question/review drops to idle", () => { @@ -261,3 +319,24 @@ describe("rowAttention", () => { expect(rowAttention({ kind: "off" })).toBeNull(); }); }); + +describe("readoutBucket (fleet readout stages)", () => { + it("folds running + backgrounding into 'running'", () => { + expect(readoutBucket({ kind: "running", activity: null })).toBe("running"); + expect(readoutBucket({ kind: "backgrounding", count: 3 })).toBe("running"); + }); + + it("folds all three attention states into 'needAttention'", () => { + expect(readoutBucket({ kind: "needInput", via: "questionnaire", prompt: null })).toBe( + "needAttention", + ); + expect(readoutBucket({ kind: "needIntervention", tool: "Bash" })).toBe("needAttention"); + expect(readoutBucket({ kind: "error", message: "x" })).toBe("needAttention"); + }); + + it("maps review to its own stage and folds idle + off into 'idle'", () => { + expect(readoutBucket({ kind: "review" })).toBe("review"); + expect(readoutBucket({ kind: "idle" })).toBe("idle"); + expect(readoutBucket({ kind: "off" })).toBe("idle"); + }); +}); diff --git a/src/agent/status.ts b/src/agent/status.ts index 6e83ab6..c55310b 100644 --- a/src/agent/status.ts +++ b/src/agent/status.ts @@ -23,10 +23,17 @@ export type AgentStatus = | { kind: "idle" } // live, nothing pending, last result already consumed | { kind: "running"; activity: string | null } // a turn is in flight | { kind: "backgrounding"; count: number } // main loop idle, but N background tools still running - | { kind: "needInput"; via: "questionnaire" | "openQuestion"; prompt: string | null } + | { kind: "needInput"; via: "questionnaire" | "openQuestion"; prompt: string | null; bg?: number } | { kind: "needIntervention"; tool: string } // a permission prompt is blocking - | { kind: "error"; message: string } // last finished turn ended in error - | { kind: "review" }; // turn finished cleanly, not yet seen by the user + | { kind: "error"; message: string; bg?: number } // last finished turn ended in error + | { kind: "review"; bg?: number }; // turn finished cleanly, not yet seen by the user + +// The `bg?` on the three SETTLED-unseen states (review / open-question / error) carries +// how many background tools are STILL running while that settled state is surfaced — +// i.e. "finished the main turn, but work continues in the background". It drives the +// distinct alert (a violet accent on the blue/orange review/question), so the user can +// tell a truly-finished agent from one that finished-but-is-waiting. Absent/0 = nothing +// in the background. See {@link backgroundCount} and {@link deriveAgentStatus}. /** * The acknowledgeable, non-blocking reminders — the ONLY statuses that persist @@ -68,6 +75,15 @@ export interface AgentSignals { * conversation is "backgrounding" — calm, still interactive — rather than idle. */ runningBackgroundTasks: number; + /** + * User preference: when a turn finishes CLEANLY while background tools are still + * running, should that surface the "review" alert (with a background accent), or go + * straight to the calm `backgrounding` state without alerting? True (default) = alert; + * false = no alert, the review only surfaces once the background work also finishes. + * Only affects the clean-finish case — an open question or an error while backgrounding + * still alerts regardless (those genuinely want the user). See Settings → Général. + */ + alertWhileBackgrounding: boolean; /** * A reminder persisted from a previous run (or while live), surfaced ONLY when * the process is off (`handle === null`): it re-displays a finished-but-unseen @@ -155,17 +171,25 @@ export function deriveAgentStatus(s: AgentSignals): AgentStatus { // Live and idle. If the last turn just finished and the user hasn't acted on it, // surface it (error / open-question / review). Otherwise there's nothing to show. + // While a background tool is still running, that count rides along as `bg` so the + // settled alert can show the "…but work continues" accent. if (!s.turnSeen) { + const bg = s.runningBackgroundTasks > 0 ? s.runningBackgroundTasks : undefined; if (s.lastTurnIsError || (s.lastTurnSubtype?.startsWith("error") ?? false)) - return { kind: "error", message: errorMessage(s.lastTurnSubtype) }; + return { kind: "error", message: errorMessage(s.lastTurnSubtype), bg }; if (looksLikeQuestion(s.lastAssistantText)) - return { kind: "needInput", via: "openQuestion", prompt: s.lastAssistantText }; - return { kind: "review" }; + return { kind: "needInput", via: "openQuestion", prompt: s.lastAssistantText, bg }; + // Clean finish. Normally this is `review`. But when background work is still running + // AND the user disabled the "alert while backgrounding" preference, we DON'T alert + // now — fall through to the calm `backgrounding` state below; the review surfaces only + // once the background work also completes (turn still unseen, but bg back to 0). + if (!(bg && !s.alertWhileBackgrounding)) return { kind: "review", bg }; } // Settled with nothing to review — but if background tools are still running, the // conversation isn't truly idle: it's quietly waiting on them (and stays - // interactive). A calm, distinct state rather than the dormant "idle". + // interactive). A calm, distinct state rather than the dormant "idle". Also reached + // by a clean finish when the "alert while backgrounding" preference is off (above). if (s.runningBackgroundTasks > 0) return { kind: "backgrounding", count: s.runningBackgroundTasks }; @@ -234,6 +258,50 @@ export function isActivelyRunning(s: AgentStatus): boolean { return s.kind === "running" || s.kind === "backgrounding"; } +/** + * How many background tools are still running behind a SETTLED-unseen alert + * (review / open-question / error) — i.e. "finished, but work continues". 0 for every + * other status (including `backgrounding`, whose count is its own `count` field, and + * whose calm violet already conveys the background work). Drives the review/question + * alert's violet accent (the dot ring + the "N en fond" chip). See {@link AgentStatus}. + */ +export function backgroundCount(s: AgentStatus): number { + switch (s.kind) { + case "review": + case "needInput": + case "error": + return s.bg ?? 0; + default: + return 0; + } +} + +/** + * Collapse a status onto one of the four fleet-readout STAGES the "Fleet readout" + * banner counts (sidebar + FlightDeck top). Coarser than {@link agentStatusToDot}: + * the calm background state (`backgrounding`) folds into `running` (work is still + * happening), the three attention states (`needInput` / `needIntervention` / `error`) + * all fold into `needAttention`, and the two dormant states (`idle` / `off`) fold + * into `idle`. Listed explicitly (not a `default`) so a new status kind is a compile + * error here until it's classified — same discipline as `rowAttention`/`statusRank`. + */ +export function readoutBucket(s: AgentStatus): "running" | "review" | "needAttention" | "idle" { + switch (s.kind) { + case "running": + case "backgrounding": + return "running"; + case "review": + return "review"; + case "needInput": + case "needIntervention": + case "error": + return "needAttention"; + case "idle": + case "off": + return "idle"; + } +} + /** * Sort key for the FlightDeck: what to surface first. Lower = more important, so * action-required and errors come first (leftmost in a lane / top repo), then diff --git a/src/agent/useAgentStatus.ts b/src/agent/useAgentStatus.ts index 3e4594a..d012253 100644 --- a/src/agent/useAgentStatus.ts +++ b/src/agent/useAgentStatus.ts @@ -10,6 +10,7 @@ import { useShallow } from "zustand/react/shallow"; import { useConversationStore } from "../store/conversationStore"; import { useConversationsStore } from "../store/conversationsStore"; import { useRunningTaskCount } from "../store/backgroundTasksStore"; +import { useDisplay } from "../store/display"; import type { SessionEntry } from "../store/types"; import { deriveAgentStatus, @@ -20,11 +21,11 @@ import { // The signals carried by the LIVE message-store entry — everything in AgentSignals // except the ones sourced elsewhere: the live `handle` and `persistedReminder` (the -// conversations/metadata store) and `runningBackgroundTasks` (the background-task -// registry). +// conversations/metadata store), `runningBackgroundTasks` (the background-task +// registry) and `alertWhileBackgrounding` (the display-prefs store). type InnerSignals = Omit< AgentSignals, - "handle" | "persistedReminder" | "runningBackgroundTasks" + "handle" | "persistedReminder" | "runningBackgroundTasks" | "alertWhileBackgrounding" >; const NEUTRAL: InnerSignals = { @@ -103,8 +104,15 @@ export function agentStatusForEntry( entry: SessionEntry | undefined, persistedReminder: ReminderKind | null = null, runningBackgroundTasks = 0, + alertWhileBackgrounding = true, ): AgentStatus { - return deriveAgentStatus({ handle, persistedReminder, runningBackgroundTasks, ...gather(entry) }); + return deriveAgentStatus({ + handle, + persistedReminder, + runningBackgroundTasks, + alertWhileBackgrounding, + ...gather(entry), + }); } /** @@ -124,8 +132,16 @@ export function useAgentStatus(convId: string): AgentStatus { ); const inner = useConversationStore(useShallow((s) => gather(s.sessions[convId]))); const runningBackgroundTasks = useRunningTaskCount(convId); + const alertWhileBackgrounding = useDisplay((s) => s.alertOnBackgroundWait); return useMemo( - () => deriveAgentStatus({ handle, persistedReminder, runningBackgroundTasks, ...inner }), - [handle, persistedReminder, runningBackgroundTasks, inner], + () => + deriveAgentStatus({ + handle, + persistedReminder, + runningBackgroundTasks, + alertWhileBackgrounding, + ...inner, + }), + [handle, persistedReminder, runningBackgroundTasks, alertWhileBackgrounding, inner], ); } diff --git a/src/features/conversation/ConductorSidebar.tsx b/src/features/conversation/ConductorSidebar.tsx index 7a5da58..1c3bc08 100644 --- a/src/features/conversation/ConductorSidebar.tsx +++ b/src/features/conversation/ConductorSidebar.tsx @@ -16,6 +16,7 @@ import { useAgentStatus } from "../../agent/useAgentStatus"; import { useRunningTaskCount } from "../../store/backgroundTasksStore"; import { agentStatusToDot, + backgroundCount, isActivelyRunning, isDismissable, rowAttention, @@ -23,6 +24,8 @@ import { } from "../../agent/status"; import { useSettingsUi } from "../../store/settingsUi"; import { useSidebarFold, useRepoCollapsed } from "../../store/sidebarFold"; +import { useDisplay } from "../../store/display"; +import { FleetReadout } from "../../ui/FleetReadout"; import { SettingsPanel } from "../settings/SettingsPanel"; import { Dot, Ico, Menu, MenuItem, MenuLabel, RunPulse } from "../../ui/kit"; import { ConfirmDialog } from "../../ui/ConfirmDialog"; @@ -35,7 +38,7 @@ import { useHistoryUi } from "../history/historyUiStore"; * flight, otherwise the plain coloured status dot (review / attention / error / idle…). */ function StatusDot({ status }: { status: AgentStatus }) { if (status.kind === "running") return <RunPulse />; - return <Dot s={agentStatusToDot(status)} pulse />; + return <Dot s={agentStatusToDot(status)} pulse ring={backgroundCount(status) > 0} />; } function ConvRow({ conv, active }: { conv: Conversation; active: boolean }) { @@ -314,6 +317,7 @@ export function ConductorSidebar() { const settingsOpen = useSettingsUi((s) => s.open); const openSettings = useSettingsUi((s) => s.openSettings); const closeSettings = useSettingsUi((s) => s.closeSettings); + const showFleet = useDisplay((s) => s.fleetBannerConversation); // Resizable width, persisted (localStorage). The grip is an absolute handle on the // right edge (reusing the editor's Splitter for pointer-capture + hover accent), so @@ -390,6 +394,11 @@ export function ConductorSidebar() { )} </div> + {/* Compact whole-fleet readout, pinned just above the Réglages footer. Counts + span every conversation (not just this repo), so it matches the FlightDeck + bar. Hidden via Settings → Général (independent of the FlightDeck toggle). */} + {showFleet ? <FleetReadout variant="sidebar" /> : null} + <button type="button" className="cv-side-foot" diff --git a/src/features/conversation/SpecialMessageCard.tsx b/src/features/conversation/SpecialMessageCard.tsx index 65afe73..9c81c20 100644 --- a/src/features/conversation/SpecialMessageCard.tsx +++ b/src/features/conversation/SpecialMessageCard.tsx @@ -1,13 +1,17 @@ // The clean render for an injected "special message" — today a `<task-notification>` // emitted when a background task/agent finishes. Shown in PLACE of a raw user bubble // (the CLI injects these AS user turns, but the human didn't type them, so no user -// avatar). Pure: renders only its parsed props, no store reads — reused VERBATIM by -// the live thread (MsgUser) and the disk transcript (SubAgentTranscript / history -// preview) so both surfaces stay identical. +// avatar). Reused VERBATIM by the live thread (MsgUser), the clean-output inline marker +// and the disk transcript (SubAgentTranscript / history preview), so all surfaces stay +// identical — INCLUDING visibility: `<task-notification>` is HIDDEN by default (it's +// machine-injected noise that clutters the transcript on reload / history import). The +// one store read here (the `showTaskNotifications` pref) is the single gate for every +// surface; the render code below is kept intact so flipping the pref on brings it back. import { Expandable } from "../../ui/Expandable"; import { Ico } from "../../ui/kit"; import { fmtDuration } from "../../agent/subagentMeta"; import { fmtTokens } from "../../store/contextData"; +import { useDisplay } from "../../store/display"; import { StreamMarkdown } from "./StreamMarkdown"; import { taskNotificationStyle, type SpecialMessage, type TaskNotification } from "./specialMessage"; @@ -62,11 +66,14 @@ function TaskNotificationCard({ n }: { n: TaskNotification }) { } /** Render an injected special message. One kind today; the switch keeps future - * injected markers (system reminders, other injections) tidy to add. */ + * injected markers (system reminders, other injections) tidy to add. Task + * notifications are hidden unless the user opts in (Settings → Général) — the + * default-off gate that keeps the transcript clean on reload / history import. */ export function SpecialMessageCard({ data }: { data: SpecialMessage }) { + const showTaskNotifications = useDisplay((s) => s.showTaskNotifications); switch (data.type) { case "task-notification": - return <TaskNotificationCard n={data} />; + return showTaskNotifications ? <TaskNotificationCard n={data} /> : null; default: return null; } diff --git a/src/features/flightdeck/AttentionBar.tsx b/src/features/flightdeck/AttentionBar.tsx deleted file mode 100644 index 5d9d905..0000000 --- a/src/features/flightdeck/AttentionBar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// The "what needs you" strip across the top of the FlightDeck. Counts come from -// the shared status model (useFleetAttention → rowAttention over every agent), NOT -// from the desktop-notification edge — so it reflects the real action-required set -// (permission / questionnaire / open question / error / review), live. -import { Dot, Ico, type StreamState } from "../../ui/kit"; -import { useFleetAttention } from "../../agent/fleet"; - -export function AttentionBar() { - const a = useFleetAttention(); - const calm = a.total === 0; - - const chips: Array<[StreamState, string, number]> = []; - if (a.needsInput + a.error > 0) chips.push(["ask", "Action requise", a.needsInput + a.error]); - if (a.review > 0) chips.push(["review", "À relire", a.review]); - - return ( - <div className={"ag-attn" + (calm ? " calm" : "")}> - <span className="wf-row" style={{ gap: 8 }}> - <span style={{ display: "inline-flex", color: calm ? "var(--wf-tx-lo)" : "var(--wf-att)" }}> - <Ico name="bell" className="sm" /> - </span> - <span className="wf-hi" style={{ fontWeight: 600 }}> - {calm - ? "Aucun stream ne demande ton attention" - : `${a.total} stream${a.total > 1 ? "s" : ""} demande${a.total > 1 ? "nt" : ""} ton attention`} - </span> - </span> - {!calm ? ( - <div className="wf-row" style={{ gap: 8, marginLeft: "auto" }}> - {chips.map(([s, label, n]) => ( - <span key={s} className="ag-attn-chip"> - <Dot s={s} pulse /> - <span className="wf-hi">{n}</span> - {label} - </span> - ))} - </div> - ) : null} - </div> - ); -} diff --git a/src/features/flightdeck/FlightDeck.tsx b/src/features/flightdeck/FlightDeck.tsx index 15e86d7..68e04c0 100644 --- a/src/features/flightdeck/FlightDeck.tsx +++ b/src/features/flightdeck/FlightDeck.tsx @@ -1,6 +1,6 @@ // FlightDeck — the agent dashboard view ("Vue Gestion d'agents"). A grid of stream -// cards grouped by repo, with an attention bar on top. Uses `useFleetLanes`, which -// shares the repo-grouping skeleton with the sidebar but orders STATUS-first +// cards grouped by repo, with the fleet readout banner on top. Uses `useFleetLanes`, +// which shares the repo-grouping skeleton with the sidebar but orders STATUS-first // (action-required/error → review → running → idle → off, recency as tiebreak) // instead of the sidebar's pure recency — a deliberate difference, so only the // grouping is shared, not the order. Each card reuses the same status/todo/context @@ -9,7 +9,8 @@ import { useEffect, useState } from "react"; import { Ico } from "../../ui/kit"; import { createConversationInRepo, repoName } from "../../store/conversationsStore"; import { useFleetLanes } from "../../agent/fleet"; -import { AttentionBar } from "./AttentionBar"; +import { useDisplay } from "../../store/display"; +import { FleetReadout } from "../../ui/FleetReadout"; import { StreamCard } from "./StreamCard"; /** A coarse clock ticking every 30s so the relative "last activity" stamps on @@ -27,6 +28,7 @@ function useNow(periodMs = 30_000): number { export function FlightDeck({ onOpen }: { onOpen: (id: string) => void }) { const groups = useFleetLanes(); const now = useNow(); + const showReadout = useDisplay((s) => s.fleetBannerFlightDeck); if (groups.length === 0) { return ( @@ -42,7 +44,7 @@ export function FlightDeck({ onOpen }: { onOpen: (id: string) => void }) { return ( <div className="ag-page wf-col"> - <AttentionBar /> + {showReadout ? <FleetReadout variant="deck" /> : null} <div className="ag-scroll wf-fade-b"> {groups.map(({ repo, conversations }) => ( <div key={repo.id} className="ag-repo"> diff --git a/src/features/flightdeck/StateBlock.tsx b/src/features/flightdeck/StateBlock.tsx index bb74704..7eb718d 100644 --- a/src/features/flightdeck/StateBlock.tsx +++ b/src/features/flightdeck/StateBlock.tsx @@ -2,14 +2,27 @@ // vocabulary, laid out for a card. Driven by the shared AgentStatus + the pending // permission, so the card and the thread always agree on what an agent is asking. import { Ico } from "../../ui/kit"; -import type { AgentStatus } from "../../agent/status"; +import { backgroundCount, type AgentStatus } from "../../agent/status"; import { classifyAsk } from "../../agent/ask"; import { usePendingPermissions } from "../../store/conversationStore"; import { questionCount } from "../conversation/QuestionnaireAsk"; import { ActivityLine } from "./ActivityLine"; +/** The violet "still working in the background" chip appended to a settled alert's + * header when the finished agent has background tools running (bg > 0). */ +function BgChip({ n }: { n: number }) { + if (n <= 0) return null; + return ( + <span className="wf-bgchip"> + <span className="sp" /> + {n} en fond + </span> + ); +} + export function StateBlock({ convId, status }: { convId: string; status: AgentStatus }) { const pending = usePendingPermissions(convId); + const bg = backgroundCount(status); if (status.kind === "running") { // A live "what it's doing now" line derived from the stream (see useLiveActivity). @@ -56,6 +69,7 @@ export function StateBlock({ convId, status }: { convId: string; status: AgentSt <div className="wf-ask-h"> <Ico name="ask" className="sm" /> Pose une question + <BgChip n={bg} /> </div> {status.prompt ? <div className="wf-ask-t">{status.prompt}</div> : null} </div> @@ -68,6 +82,7 @@ export function StateBlock({ convId, status }: { convId: string; status: AgentSt <div className="wf-ask-h"> <Ico name="alert" className="sm" /> Erreur + <BgChip n={bg} /> </div> <div className="wf-ask-t">{status.message}</div> </div> @@ -80,8 +95,13 @@ export function StateBlock({ convId, status }: { convId: string; status: AgentSt <div className="wf-review-h"> <Ico name="check" className="sm" /> À relire + <BgChip n={bg} /> + </div> + <div className="wf-review-t"> + {bg > 0 + ? "Conversation terminée — une tâche de fond continue." + : "Conversation terminée — prête à relire."} </div> - <div className="wf-review-t">Conversation terminée — prête à relire.</div> </div> ); } diff --git a/src/features/flightdeck/StreamCard.tsx b/src/features/flightdeck/StreamCard.tsx index 02fc904..6ac4458 100644 --- a/src/features/flightdeck/StreamCard.tsx +++ b/src/features/flightdeck/StreamCard.tsx @@ -5,7 +5,7 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { Dot, Pill, ContextMeter, Ico } from "../../ui/kit"; import { useAgentStatus } from "../../agent/useAgentStatus"; -import { agentStatusToDot, rowAttention } from "../../agent/status"; +import { agentStatusToDot, backgroundCount, rowAttention } from "../../agent/status"; import { effortLabel } from "../../agent/subagentMeta"; import { useSessionState } from "../../store/conversationStore"; import { useContextData } from "../../store/contextData"; @@ -85,7 +85,7 @@ export function StreamCard({ return ( <div className={cls} onClick={onCardClick}> <div className="ag-card-h"> - <Dot s={dot} pulse /> + <Dot s={dot} pulse ring={backgroundCount(status) > 0} /> <button className="ag-card-name" onClick={() => onOpen(conv.id)} title={conv.name}> {conv.name} </button> diff --git a/src/features/settings/ConversationSection.tsx b/src/features/settings/ConversationSection.tsx index 3332c28..1eca209 100644 --- a/src/features/settings/ConversationSection.tsx +++ b/src/features/settings/ConversationSection.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef } from "react"; import { StreamMarkdown } from "../conversation/StreamMarkdown"; import { useDisplay, type MarkdownMode } from "../../store/display"; import { ClaudeMark, Ico } from "../../ui/kit"; -import styles from "./SettingsPanel.module.css"; +import { PageHead } from "./SettingsKit"; const MODES: Array<{ id: MarkdownMode; label: string; desc: string }> = [ { id: "classic", label: "Classic", desc: "Le rendu historique, encadré (GitHub)." }, @@ -69,11 +69,15 @@ export function ConversationSection() { return ( <div className="mdset"> - <div className={styles.section}>Rendu Markdown</div> - <div className="mdset-intro"> - Le style de rendu du Markdown, appliqué partout : conversations, sous-agents et - aperçu des fichiers <code>.md</code>. Choisis un mode — l'aperçu montre le résultat. - </div> + <PageHead + title="Conversation" + subtitle={ + <> + Le style de rendu du Markdown, appliqué partout : conversations, sous-agents et aperçu + des fichiers <code>.md</code>. Choisis un mode — l'aperçu montre le résultat. + </> + } + /> <div className="mdset-body"> <div className="mdset-rail" role="group" aria-label="Mode de rendu Markdown"> {MODES.map((m) => ( diff --git a/src/features/settings/NotificationsSection.tsx b/src/features/settings/NotificationsSection.tsx index ad7cd25..b1f0958 100644 --- a/src/features/settings/NotificationsSection.tsx +++ b/src/features/settings/NotificationsSection.tsx @@ -1,10 +1,9 @@ // "Notifications" section of the Settings panel. Three independently-toggleable // channels fired when an agent finishes a turn or needs attention. Prefs live in // the notifications store; the actual dispatch is in src/notifications/. -import type { ReactNode } from "react"; import { useNotifications } from "../../store/notifications"; import { testSound } from "../../notifications/notify"; -import { Toggle } from "../../ui/Toggle"; +import { PageHead, SettingsGroup, ToggleRow } from "./SettingsKit"; import styles from "./SettingsPanel.module.css"; export function NotificationsSection() { @@ -15,13 +14,12 @@ export function NotificationsSection() { return ( <div> - <div className={styles.section}>Notifications</div> - <div className={styles.desc}> - Quand un agent termine son tour ou a besoin de ton attention (permission, question). - Rien ne se déclenche si tu regardes déjà la conversation concernée. - </div> + <PageHead + title="Notifications" + subtitle="Quand un agent termine son tour ou a besoin de ton attention (permission, question). Rien ne se déclenche si tu regardes déjà la conversation concernée." + /> - <div className={styles.toggleList}> + <SettingsGroup title="Canaux" icon="bell"> <ToggleRow title="Notification système" hint="Une bannière macOS dans le Centre de notifications." @@ -49,32 +47,7 @@ export function NotificationsSection() { checked={dockBounce} onChange={(v) => set({ dockBounce: v })} /> - </div> - </div> - ); -} - -function ToggleRow({ - title, - hint, - checked, - onChange, - action, -}: { - title: string; - hint: string; - checked: boolean; - onChange: (next: boolean) => void; - action?: ReactNode; -}) { - return ( - <div className={styles.toggleRow}> - <div className={styles.toggleText}> - <div className={styles.toggleTitle}>{title}</div> - <div className={styles.toggleHint}>{hint}</div> - </div> - {action} - <Toggle checked={checked} onChange={onChange} label={title} /> + </SettingsGroup> </div> ); } diff --git a/src/features/settings/SettingsKit.tsx b/src/features/settings/SettingsKit.tsx new file mode 100644 index 0000000..44e5042 --- /dev/null +++ b/src/features/settings/SettingsKit.tsx @@ -0,0 +1,79 @@ +// Shared building blocks for the Settings panel — the page heading, the titled +// "group card", and a toggle row inside it. Every tab composes these so the whole +// panel stays visually consistent (see SettingsPanel.module.css). All three import +// the SAME CSS module as SettingsPanel.tsx, so the hashed class names line up. +import type { ReactNode } from "react"; +import { Ico } from "../../ui/kit"; +import { Toggle } from "../../ui/Toggle"; +import styles from "./SettingsPanel.module.css"; + +/** The heading at the top of a settings tab: a bold title + a muted one-liner. */ +export function PageHead({ title, subtitle }: { title: string; subtitle?: ReactNode }) { + return ( + <div className={styles.pageHead}> + <div className={styles.pageTitle}>{title}</div> + {subtitle ? <div className={styles.pageSub}>{subtitle}</div> : null} + </div> + ); +} + +/** A titled card grouping related settings. `icon` is a kit glyph name shown in coral + * next to the (uppercase) group title; the children are the rows inside the card. */ +export function SettingsGroup({ + title, + icon, + children, +}: { + title: string; + icon?: string; + children: ReactNode; +}) { + return ( + <section className={styles.group}> + <div className={styles.groupHead}> + {icon ? ( + <span className={styles.groupIco}> + <Ico name={icon} className="sm" /> + </span> + ) : null} + <span className={styles.groupTitle}>{title}</span> + </div> + <div className={styles.card}>{children}</div> + </section> + ); +} + +/** A single row inside a {@link SettingsGroup} card: a title + optional hint on the + * left, and a control on the right — a {@link Toggle} by default, or `control` for a + * custom right-hand element. `action` places an extra element just left of the toggle + * (e.g. a "Tester" button). */ +export function ToggleRow({ + title, + hint, + checked, + onChange, + label, + action, + control, +}: { + title: string; + hint?: ReactNode; + checked?: boolean; + onChange?: (next: boolean) => void; + label?: string; + action?: ReactNode; + control?: ReactNode; +}) { + return ( + <div className={styles.trow}> + <div className={styles.ttext}> + <div className={styles.ttitle}>{title}</div> + {hint ? <div className={styles.thint}>{hint}</div> : null} + </div> + {action} + {control ?? ( + <Toggle checked={!!checked} onChange={onChange ?? (() => {})} label={label ?? title} /> + )} + </div> + ); +} diff --git a/src/features/settings/SettingsPanel.module.css b/src/features/settings/SettingsPanel.module.css index 19771ad..f56f6ff 100644 --- a/src/features/settings/SettingsPanel.module.css +++ b/src/features/settings/SettingsPanel.module.css @@ -19,38 +19,59 @@ max-height: 86vh; background: var(--wf-panel); border: 1px solid var(--wf-line); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-overlay); + border-radius: 16px; + box-shadow: 0 30px 80px -24px #000, 0 0 0 1px #0006; overflow: hidden; } .head { display: flex; align-items: center; - gap: var(--space-3); - padding: 14px 16px; + gap: 11px; + padding: 15px 18px; border-bottom: 1px solid var(--wf-line); flex: none; } +/* Branded cog chip in the header — a small elevated tile with the coral accent. */ +.headIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + flex: none; + border-radius: 9px; + background: linear-gradient(135deg, #1c1512, #141118); + border: 1px solid var(--border-strong); + color: var(--accent); +} +.headIcon :global(.wf-ico) { + width: 16px; + height: 16px; +} + .title { flex: 1; font-weight: 600; color: var(--text-primary); + font-size: 15px; + letter-spacing: -0.2px; } .close { display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 28px; + height: 28px; border: 0; - border-radius: var(--radius-sm); + border-radius: 8px; background: transparent; - color: var(--text-secondary); + color: var(--text-muted); font-size: 15px; cursor: pointer; + transition: background var(--transition-hover), color var(--transition-hover); } .close:hover { background: var(--bg-hover); @@ -67,23 +88,34 @@ .rail { flex: none; - width: 188px; - padding: var(--space-3); + width: 210px; + padding: 12px 10px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--wf-line); overflow-y: auto; + background: linear-gradient(180deg, #121217, var(--wf-panel)); +} + +.railCap { + font-size: 9.5px; + letter-spacing: 1.4px; + text-transform: uppercase; + color: var(--wf-tx-xlo); + font-weight: 600; + padding: 6px 10px 8px; } .railItem { + position: relative; display: flex; align-items: center; - gap: var(--space-3); + gap: 11px; width: 100%; - padding: 8px 10px; - border: 0; - border-radius: var(--radius-sm); + padding: 9px 11px; + border: 1px solid transparent; + border-radius: 9px; background: transparent; color: var(--text-secondary); font-family: var(--font-ui); @@ -91,90 +123,193 @@ font-weight: 500; text-align: left; cursor: pointer; - transition: background var(--transition-hover), color var(--transition-hover); + transition: background var(--transition-hover), color var(--transition-hover), + border-color var(--transition-hover); +} +.railItem :global(.wf-ico) { + color: var(--text-muted); + transition: color var(--transition-hover); } .railItem:hover { - background: var(--bg-hover); + background: #ffffff06; color: var(--text-primary); } +/* Active tab — coral-tinted fill, coral icon, and a glowing left accent bar. */ .railItem[data-on] { - background: var(--bg-hover); + background: linear-gradient(90deg, #2a1c15, #1a1512 80%); + border-color: #43301f; color: var(--text-primary); } +.railItem[data-on] :global(.wf-ico) { + color: var(--accent); +} +.railItem[data-on]::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2.5px; + border-radius: 2px; + background: var(--accent); + box-shadow: 0 0 10px -1px var(--accent); +} .content { flex: 1; min-width: 0; - padding: 20px 22px; + padding: 26px 30px; overflow-y: auto; } -/* ---- Shared section primitives ------------------------------------------ */ +/* ---- Page heading (top of each tab) ------------------------------------- */ -.section { - font-size: var(--fs-xs); - text-transform: uppercase; - letter-spacing: 0.4px; - color: var(--text-muted); - margin-bottom: var(--space-3); +.pageHead { + margin-bottom: 24px; } - -.desc { - font-size: var(--fs-base); - color: var(--text-secondary); +.pageTitle { + font-size: 19px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.3px; +} +.pageSub { + color: var(--text-muted); + font-size: 12.5px; + margin-top: 3px; line-height: var(--lh-body); - margin-bottom: var(--space-4); } -.about { +/* ---- Settings group: a titled card of rows ------------------------------ */ + +.group { + margin-bottom: 22px; +} +.groupHead { display: flex; - align-items: baseline; - gap: var(--space-3); - margin-bottom: var(--space-3); + align-items: center; + gap: 8px; + margin: 0 4px 9px; } -.appName { - font-weight: 600; - color: var(--text-primary); +.groupIco { + display: inline-flex; + color: var(--accent); } -.version { - font-size: var(--fs-sm); +.groupIco :global(.wf-ico) { + width: 14px; + height: 14px; +} +.groupTitle { + font-size: 11px; + letter-spacing: 1.2px; + text-transform: uppercase; color: var(--text-muted); - font-variant-numeric: tabular-nums; + font-weight: 600; } - -/* ---- Toggle rows (Notifications) ---------------------------------------- */ - -.toggleList { - display: flex; - flex-direction: column; +.card { + border: 1px solid var(--wf-line); + border-radius: 12px; + background: var(--bg-elevated); + overflow: hidden; } -.toggleRow { +/* A row inside a card — title + hint on the left, control on the right. */ +.trow { display: flex; align-items: center; - gap: var(--space-3); - padding: var(--space-3) 0; + gap: 16px; + padding: 13px 15px; + transition: background var(--transition-hover); +} +.trow + .trow { border-top: 1px solid var(--wf-line); } -.toggleRow:last-child { - border-bottom: 1px solid var(--wf-line); +.trow:hover { + background: #ffffff03; } - -.toggleText { +.ttext { flex: 1; min-width: 0; } -.toggleTitle { - font-size: var(--fs-base); - font-weight: 500; +.ttitle { + font-size: 13px; + font-weight: 600; color: var(--text-primary); } -.toggleHint { - font-size: var(--fs-sm); +.thint { + font-size: 12px; color: var(--text-muted); + line-height: 1.5; + margin-top: 3px; +} +.thint code { + font-family: var(--font-mono, ui-monospace, Menlo, monospace); + font-size: 11px; + background: var(--wf-bg-2); + border: 1px solid var(--wf-line); + border-radius: 4px; + padding: 1px 5px; + color: var(--text-secondary); +} + +/* ---- Shared section primitives ------------------------------------------ */ + +.desc { + font-size: var(--fs-base); + color: var(--text-secondary); line-height: var(--lh-body); + margin-bottom: var(--space-4); +} + +/* About hero — a soft card with the app mark, name/tagline and a version pill. */ +.about { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + margin-bottom: 26px; + border: 1px solid var(--wf-line); + border-radius: 12px; + background: linear-gradient(180deg, var(--bg-elevated), #141419); +} +.aboutMark { + width: 42px; + height: 42px; + flex: none; + border-radius: 11px; + /* Dark tile so the brand mark's own coral + cyan fills read cleanly (a coral + background would wash out the coral "T"). */ + background: linear-gradient(135deg, #1c1512, #131118); + border: 1px solid var(--border-strong); + display: grid; + place-items: center; + box-shadow: 0 6px 18px -10px #000; +} +.aboutMark svg { + width: 24px; + height: 24px; +} +.appName { + font-weight: 700; + color: var(--text-primary); + font-size: 15px; +} +.appTag { + color: var(--text-muted); + font-size: 12px; margin-top: 2px; } +.version { + margin-left: auto; + font-size: var(--fs-sm); + color: var(--text-secondary); + font-family: var(--font-mono, ui-monospace, Menlo, monospace); + font-variant-numeric: tabular-nums; + background: var(--bg-hover); + border: 1px solid var(--border-strong); + border-radius: var(--radius-pill); + padding: 4px 11px; +} /* ---- Buttons ------------------------------------------------------------ */ @@ -366,20 +501,25 @@ /* ---- Raccourcis (Shortcuts) tab ------------------------------------------- */ .scGroup { - margin-bottom: var(--space-5); + margin-bottom: 22px; } .scGroupTitle { - font-size: var(--fs-xs); + font-size: 11px; text-transform: uppercase; - letter-spacing: 0.4px; + letter-spacing: 1.2px; color: var(--text-muted); - margin-bottom: var(--space-2); + font-weight: 600; + margin: 0 4px 9px; } .scList { display: flex; flex-direction: column; + border: 1px solid var(--wf-line); + border-radius: 12px; + background: var(--bg-elevated); + overflow: hidden; } .scRow { @@ -387,16 +527,19 @@ align-items: center; justify-content: space-between; gap: var(--space-4); - padding: var(--space-2) 0; - border-bottom: 1px solid var(--border); + padding: 10px 15px; + border-bottom: 1px solid var(--wf-line); } .scRow:last-child { border-bottom: none; } +.scRow:hover { + background: #ffffff03; +} .scLabel { - font-size: var(--fs-base); + font-size: 13px; color: var(--text-secondary); line-height: var(--lh-body); } diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index d1baac1..cf64d75 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -9,11 +9,12 @@ import { wipeAllData } from "../../store/conversationsStore"; import { useSettingsUi, type SettingsSection } from "../../store/settingsUi"; import { useDisplay } from "../../store/display"; import { Ico } from "../../ui/kit"; -import { Toggle } from "../../ui/Toggle"; +import { TosseMark } from "../../ui/TosseMark"; import { UpdateSection } from "./UpdateSection"; import { NotificationsSection } from "./NotificationsSection"; import { ConversationSection } from "./ConversationSection"; import { ShortcutsSection } from "./ShortcutsSection"; +import { PageHead, SettingsGroup, ToggleRow } from "./SettingsKit"; import styles from "./SettingsPanel.module.css"; const TABS: Array<{ id: SettingsSection; label: string; icon: string }> = [ @@ -80,7 +81,9 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => <div className={styles.scrim} onClick={close}> <div className={styles.panel} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal> <div className={styles.head}> - <Ico name="cog" className="sm" /> + <span className={styles.headIcon}> + <Ico name="cog" className="sm" /> + </span> <span className={styles.title}>Réglages</span> <button className={styles.close} onClick={close} title="Fermer" aria-label="Fermer"> ✕ @@ -89,6 +92,7 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => <div className={styles.layout}> <nav className={styles.rail} aria-label="Sections des réglages"> + <div className={styles.railCap}>Réglages</div> {TABS.map((t) => ( <button key={t.id} @@ -106,16 +110,24 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => <div className={styles.content}> {section === "general" && ( <div> - <div className={styles.section}>À propos</div> + <PageHead title="Général" subtitle="Apparence, flotte et alertes de l'application." /> + <div className={styles.about}> - <span className={styles.appName}>Tosse Code</span> + <span className={styles.aboutMark}> + <TosseMark /> + </span> + <div> + <div className={styles.appName}>Tosse Code</div> + <div className={styles.appTag}> + Application de bureau pour piloter Claude Code. + </div> + </div> {version && <span className={styles.version}>v{version}</span>} </div> - <div className={styles.desc}> - Application de bureau pour piloter Claude Code. - </div> <DisplayPrefs /> + <FleetBannerPrefs /> + <AgentAlertPrefs /> </div> )} @@ -129,7 +141,10 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => {section === "data" && ( <div> - <div className={styles.section}>Données</div> + <PageHead + title="Données" + subtitle="Gestion des données locales de l'application." + /> <div className={styles.desc}> Supprime toutes les conversations et tous les dépôts enregistrés, et vide la base locale. Les transcripts de Claude sur le disque ne sont pas touchés. Action @@ -176,30 +191,94 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => * each conversation's composer chip can override it (per-conversation, persisted). */ function DisplayPrefs() { const cleanOutput = useDisplay((s) => s.cleanOutput); + const showTaskNotifications = useDisplay((s) => s.showTaskNotifications); const set = useDisplay((s) => s.set); return ( - <> - <div className={styles.section} style={{ marginTop: 22 }}> - Affichage - </div> - <div className={styles.toggleList}> - <div className={styles.toggleRow}> - <div className={styles.toggleText}> - <div className={styles.toggleTitle}>Clean output (par défaut)</div> - <div className={styles.toggleHint}> - N'affiche que le message final de chaque réponse ; les outils, la réflexion et les - étapes intermédiaires sont repliés derrière un bloc « Travail de Claude », dépliable - à la demande. Réglage <strong>par défaut</strong> : chaque conversation peut le - surcharger via son bouton « Clean output ». - </div> - </div> - <Toggle - checked={cleanOutput} - onChange={(v) => set({ cleanOutput: v })} - label="Clean output par défaut" - /> - </div> - </div> - </> + <SettingsGroup title="Affichage" icon="list"> + <ToggleRow + title="Clean output (par défaut)" + hint={ + <> + N'affiche que le message final de chaque réponse ; les outils, la réflexion et les + étapes intermédiaires sont repliés derrière un bloc « Travail de Claude », dépliable à + la demande. Réglage <strong>par défaut</strong> : chaque conversation peut le surcharger + via son bouton « Clean output ». + </> + } + checked={cleanOutput} + onChange={(v) => set({ cleanOutput: v })} + label="Clean output par défaut" + /> + <ToggleRow + title="Notifications de tâche de fond" + hint={ + <> + Affiche les messages <code><task-notification></code> (injectés par le CLI quand + une tâche de fond ou un sous-agent se termine) dans le fil. <strong>Désactivé par + défaut</strong> : ils encombrent la conversation, surtout au rechargement ou à l'import + depuis l'historique. + </> + } + checked={showTaskNotifications} + onChange={(v) => set({ showTaskNotifications: v })} + label="Afficher les notifications de tâche de fond" + /> + </SettingsGroup> + ); +} + +/** The two independent toggles for the "Fleet readout" banner — the adaptive stage + * counts ("N Running · N Review · …") across the whole fleet. One controls the wide + * bar at the top of the FlightDeck, the other the compact box at the bottom of the + * conversation sidebar; they're deliberately separate so either surface can be hidden + * on its own. Both on by default (see store/display DEFAULTS). */ +function FleetBannerPrefs() { + const flightDeck = useDisplay((s) => s.fleetBannerFlightDeck); + const conversation = useDisplay((s) => s.fleetBannerConversation); + const set = useDisplay((s) => s.set); + return ( + <SettingsGroup title="Bandeau de flotte" icon="grid"> + <ToggleRow + title="Afficher dans le Flight Deck" + hint="Le résumé de la flotte (Running · Review · Need Attention · Idle) en haut du Flight Deck." + checked={flightDeck} + onChange={(v) => set({ fleetBannerFlightDeck: v })} + label="Bandeau de flotte dans le Flight Deck" + /> + <ToggleRow + title="Afficher dans la Conversation" + hint="Le même résumé, en version compacte, en bas de la barre latérale des conversations." + checked={conversation} + onChange={(v) => set({ fleetBannerConversation: v })} + label="Bandeau de flotte dans la Conversation" + /> + </SettingsGroup> + ); +} + +/** Whether an agent that finishes its main turn while a background task is still + * running raises the "à relire" alert (with a violet background accent) or goes + * straight to the calm violet "backgrounding" state without alerting. On by default. */ +function AgentAlertPrefs() { + const alertOnBackgroundWait = useDisplay((s) => s.alertOnBackgroundWait); + const set = useDisplay((s) => s.set); + return ( + <SettingsGroup title="Alertes d'agent" icon="alert"> + <ToggleRow + title="Alerter en attendant une tâche de fond" + hint={ + <> + Quand un agent termine sa réponse mais qu'une <strong>tâche de fond</strong> tourne + encore, lever l'alerte « à relire » (avec un accent violet indiquant le travail en + cours). <strong>Désactivé</strong> : pas d'alerte, l'agent passe directement en violet + (tâche de fond) ; le « à relire » n'apparaît qu'une fois la tâche de fond aussi terminée. + Une question ou une erreur alerte toujours. + </> + } + checked={alertOnBackgroundWait} + onChange={(v) => set({ alertOnBackgroundWait: v })} + label="Alerter en attendant une tâche de fond" + /> + </SettingsGroup> ); } diff --git a/src/features/settings/ShortcutsSection.tsx b/src/features/settings/ShortcutsSection.tsx index 4af4cbe..ebcc58f 100644 --- a/src/features/settings/ShortcutsSection.tsx +++ b/src/features/settings/ShortcutsSection.tsx @@ -3,6 +3,7 @@ // — the SAME source the global handler dispatches from for the new chords — so what's // documented here can never drift from what's wired. import { SHORTCUT_GROUPS } from "../../ui/shortcuts"; +import { PageHead } from "./SettingsKit"; import styles from "./SettingsPanel.module.css"; /** Split a display chord ("⌘⌥ ↑ / ⌘⌥ ↓") into the tokens we render as separate keycaps. @@ -15,11 +16,10 @@ function keycaps(keys: string): string[] { export function ShortcutsSection() { return ( <div> - <div className={styles.section}>Raccourcis clavier</div> - <div className={styles.desc}> - Les raccourcis disponibles dans l'application. Sur macOS, ⌘ est la touche Commande ; sous - Windows/Linux, utilisez Ctrl à la place. - </div> + <PageHead + title="Raccourcis clavier" + subtitle="Les raccourcis disponibles dans l'application. Sur macOS, ⌘ est la touche Commande ; sous Windows/Linux, utilisez Ctrl à la place." + /> {SHORTCUT_GROUPS.map((group) => ( <div key={group.title} className={styles.scGroup}> diff --git a/src/features/settings/UpdateSection.tsx b/src/features/settings/UpdateSection.tsx index ea53720..35762c7 100644 --- a/src/features/settings/UpdateSection.tsx +++ b/src/features/settings/UpdateSection.tsx @@ -10,6 +10,7 @@ import { useConversationsStore } from "../../store/conversationsStore"; import { StreamMarkdown } from "../conversation/StreamMarkdown"; import { ConfirmDialog } from "../../ui/ConfirmDialog"; import { Ico } from "../../ui/kit"; +import { PageHead } from "./SettingsKit"; import styles from "./SettingsPanel.module.css"; function formatBytes(n: number): string { @@ -46,7 +47,7 @@ export function UpdateSection() { return ( <div> - <div className={styles.section}>Mise à jour</div> + <PageHead title="Mises à jour" subtitle="Version installée, nouveautés et installation." /> {hasUpdate && update ? ( <> diff --git a/src/ipc/useGlobalSessionEvents.ts b/src/ipc/useGlobalSessionEvents.ts index 7d5d388..4c2bcdf 100644 --- a/src/ipc/useGlobalSessionEvents.ts +++ b/src/ipc/useGlobalSessionEvents.ts @@ -25,9 +25,11 @@ import type { SessionSummaryEvent, } from "./client"; import { useConversationStore } from "../store/conversationStore"; -import { useBackgroundTasksStore } from "../store/backgroundTasksStore"; +import { useBackgroundTasksStore, runningCountsByConv } from "../store/backgroundTasksStore"; import { useWorkflowLiveStore } from "../store/workflowLive"; import { useConversationsStore, repoName } from "../store/conversationsStore"; +import { useDisplay } from "../store/display"; +import { agentStatusForEntry } from "../agent/useAgentStatus"; import { useCommandsStore } from "../store/commandsStore"; import { useRemoteControlStore } from "../store/remoteControl"; import { useLastMessageSummaryStore } from "../store/lastMessageSummary"; @@ -82,6 +84,27 @@ function notifyTransition( const convs = useConversationsStore.getState(); const conv = convs.conversations.find((c) => c.id === convId); if (!conv) return; + + // Suppress the "done" ping when the finish lands the agent in `backgrounding` — i.e. + // it finished cleanly but a background task is still running AND the user disabled the + // "alert while backgrounding" preference. deriveAgentStatus already encodes that rule, + // so we just check the resulting status: no extra branching here keeps the visual and + // the notification in lock-step. An open question / error / (alert-on) review does NOT + // derive to `backgrounding`, so those still ping. + if (kind === "done") { + const bg = runningCountsByConv(useBackgroundTasksStore.getState().sessions)[convId] ?? 0; + if (bg > 0) { + const status = agentStatusForEntry( + conv.handle, + useConversationStore.getState().sessions[convId], + conv.pendingReminder, + bg, + useDisplay.getState().alertOnBackgroundWait, + ); + if (status.kind === "backgrounding") return; + } + } + const repo = convs.repos.find((r) => r.id === conv.repoId); dispatchAgentNotification({ kind, diff --git a/src/main.tsx b/src/main.tsx index cb079ea..a3c4a02 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import "./ui/conductor-conversation.css"; import "./ui/conductor-flightdeck.css"; import "./ui/conductor-aliases.css"; import "./ui/conductor-markdown-modes.css"; +import "./ui/conductor-fleet.css"; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, diff --git a/src/store/backgroundTasksStore.ts b/src/store/backgroundTasksStore.ts index 0049410..8dee31c 100644 --- a/src/store/backgroundTasksStore.ts +++ b/src/store/backgroundTasksStore.ts @@ -185,6 +185,26 @@ export const useRunningTaskCount = (session: string): number => return n; }); +/** Running background-task count for EVERY conversation at once, `{ convId → n }`, + * omitting the zeros so the object stays small and shallow-stable — re-renders only + * when some conversation's count actually moves (a task starts/stops), not on the + * frequent progress/token ticks. Feeds the fleet-wide status derivation (readout + + * lanes), which must reflect background work without mounting one hook per card. */ +export function runningCountsByConv( + sessions: Record<string, Record<string, BackgroundTask>>, +): Record<string, number> { + const out: Record<string, number> = {}; + for (const [conv, tasks] of Object.entries(sessions)) { + let n = 0; + for (const t of Object.values(tasks)) if (t.status === "running") n++; + if (n > 0) out[conv] = n; + } + return out; +} + +export const useRunningCountsByConv = (): Record<string, number> => + useBackgroundTasksStore(useShallow((s) => runningCountsByConv(s.sessions))); + /** * The background task spawned by a given `tool_use` block (an `Agent` / `Bash` / * `Monitor` / `Workflow` card), matched on `tool_use_id`, or `undefined` if the diff --git a/src/store/display.ts b/src/store/display.ts index 2bfcc8b..2ea4400 100644 --- a/src/store/display.ts +++ b/src/store/display.ts @@ -33,6 +33,31 @@ export interface DisplayPrefs { /** The Markdown rendering look, applied globally to every surface that renders * Markdown. See {@link MarkdownMode}. Set from Settings → Conversation. */ markdownMode: MarkdownMode; + + /** Show the "Fleet readout" banner (the adaptive "N Running · N Review · …" stage + * counts across the whole fleet) at the TOP of the FlightDeck. On by default. Set + * from Settings → Général. Independent of {@link fleetBannerConversation}. */ + fleetBannerFlightDeck: boolean; + + /** Show the compact "Fleet readout" box at the BOTTOM of the conversation sidebar. + * On by default. Set from Settings → Général. Independent of + * {@link fleetBannerFlightDeck}. */ + fleetBannerConversation: boolean; + + /** When an agent finishes its main turn CLEANLY while a background task is still + * running, should it raise the "à relire" alert (with a background accent), or go + * straight to the calm violet `backgrounding` state without alerting? On by default + * (alert). Off → no alert while waiting on the background task; the review surfaces + * only once that work also finishes. An open question / error while backgrounding + * still alerts regardless. Read by {@link deriveAgentStatus} via `alertWhileBackgrounding`. */ + alertOnBackgroundWait: boolean; + + /** Show the CLI-injected `<task-notification>` messages (a background task/agent + * finished) in the conversation thread. OFF by default — they're machine-injected + * noise that clutters the transcript, especially on reload / history import. The + * clean render (SpecialMessageCard) is kept, just gated: flip this on to see them + * again. Read by {@link SpecialMessageCard}. */ + showTaskNotifications: boolean; } // Off by default: the transcript shows everything inline as before. The user opts in @@ -42,6 +67,10 @@ export interface DisplayPrefs { const DEFAULTS: DisplayPrefs = { cleanOutput: false, markdownMode: "warm", + fleetBannerFlightDeck: true, + fleetBannerConversation: true, + alertOnBackgroundWait: true, + showTaskNotifications: false, }; function load(): DisplayPrefs { @@ -76,6 +105,10 @@ export const useDisplay = create<DisplayState>((set) => ({ const next: DisplayPrefs = { cleanOutput: patch.cleanOutput ?? s.cleanOutput, markdownMode: patch.markdownMode ?? s.markdownMode, + fleetBannerFlightDeck: patch.fleetBannerFlightDeck ?? s.fleetBannerFlightDeck, + fleetBannerConversation: patch.fleetBannerConversation ?? s.fleetBannerConversation, + alertOnBackgroundWait: patch.alertOnBackgroundWait ?? s.alertOnBackgroundWait, + showTaskNotifications: patch.showTaskNotifications ?? s.showTaskNotifications, }; save(next); return next; diff --git a/src/ui/FleetReadout.tsx b/src/ui/FleetReadout.tsx new file mode 100644 index 0000000..f6a510e --- /dev/null +++ b/src/ui/FleetReadout.tsx @@ -0,0 +1,82 @@ +// The "Fleet readout" — an adaptive one-line summary of the whole fleet's agents by +// stage: "3 Running · 1 Review · 2 Need Attention · 5 Idle". Coloured numbers, no +// dots or icons, every stage rendered identically (Need Attention gets NO special +// emphasis — a deliberate call). Only non-zero stages show, activity-first order; when +// nothing is active it reads "Fleet rests · N Idle". Counts span EVERY conversation +// (whole fleet), so the sidebar box and the FlightDeck bar always agree. +// +// Two placements share this component via `variant`: +// - "deck" → the wide bar at the top of the FlightDeck (replaces the old +// AttentionBar), with the fleet total on the right. +// - "sidebar" → a compact box pinned at the bottom of the conversation sidebar. +// +// Both are gated by their own Settings toggle at the call site, so this component only +// decides WHAT to render, never WHETHER to appear (beyond the empty-fleet guard). +import { Fragment } from "react"; +import { + useFleetCounts, + fleetSegments, + mergedFleetSegments, + isFleetCalm, + type FleetCounts, +} from "../agent/fleet"; + +/** The stage list, or the calm "Fleet rests · N Idle" phrasing. Shared by both + * variants; the deck lists all four stages, the sidebar the merged three + * ({@link mergedFleetSegments}: Review folded into Attention) so the words fit one + * line in the narrow panel. */ +function ReadoutLine({ c, merged }: { c: FleetCounts; merged?: boolean }) { + if (isFleetCalm(c)) { + return ( + <span className="fr-line"> + <span className="fr-rest">Fleet rests</span> + <span className="fr-sep">·</span> + <span className="fr-seg idle"> + <span className="fr-n">{c.idle}</span> + <span className="fr-l">Idle</span> + </span> + </span> + ); + } + const segs = merged ? mergedFleetSegments(c) : fleetSegments(c); + return ( + <span className="fr-line"> + {segs.map((s, i) => ( + <Fragment key={s.key}> + {i > 0 ? <span className="fr-sep">·</span> : null} + <span className={"fr-seg " + s.key}> + <span className="fr-n">{s.count}</span> + <span className="fr-l">{s.label}</span> + </span> + </Fragment> + ))} + </span> + ); +} + +export function FleetReadout({ variant }: { variant: "deck" | "sidebar" }) { + const c = useFleetCounts(); + // Nothing to summarise with an empty fleet — hide entirely rather than show a bare + // "Fleet rests · 0 Idle". (The FlightDeck already early-returns on no repos, but the + // sidebar can be empty too.) + if (c.total === 0) return null; + + if (variant === "sidebar") { + // Compact, single-line, footer-styled (like the Réglages row just below it) — the + // merged three-stage view keeps it discreet in the narrow panel. + return ( + <div className="fr-side"> + <ReadoutLine c={c} merged /> + </div> + ); + } + + return ( + <div className="fr-deck"> + <ReadoutLine c={c} /> + <span className="fr-total"> + <span className="fr-total-n">{c.total}</span> agents + </span> + </div> + ); +} diff --git a/src/ui/conductor-fleet.css b/src/ui/conductor-fleet.css new file mode 100644 index 0000000..755283f --- /dev/null +++ b/src/ui/conductor-fleet.css @@ -0,0 +1,109 @@ +/* Fleet readout — the adaptive "N Running · N Review · …" stage summary. One line, + coloured numbers, no dots/icons; every stage rendered identically. Two placements + share the inner line (.fr-line) and differ only in chrome: .fr-deck (FlightDeck top + bar) and .fr-side (compact sidebar box). See ui/FleetReadout.tsx. */ + +.fr-line { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; +} + +.fr-seg { + display: inline-flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; +} + +.fr-n { + font-family: var(--wf-mono); + font-weight: 700; + letter-spacing: -0.5px; + font-variant-numeric: tabular-nums; +} + +.fr-l { + color: var(--wf-tx-lo); + font-weight: 500; +} + +.fr-sep { + color: var(--wf-tx-xlo); + font-weight: 400; +} + +.fr-rest { + color: var(--wf-tx-lo); + font-weight: 500; +} + +/* Stage colours ride the NUMBER only — the label stays quiet. Uniform across stages: + Need Attention gets no extra emphasis (pill / halo), by design. */ +.fr-seg.running .fr-n { + color: var(--wf-run); +} +.fr-seg.review .fr-n { + color: var(--wf-wait); +} +.fr-seg.needAttention .fr-n { + color: var(--wf-att); +} +.fr-seg.idle .fr-n { + color: var(--wf-done); +} + +/* ── FlightDeck top bar (replaces the old .ag-attn) ── */ +.fr-deck { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 20px; + border-bottom: 1px solid var(--wf-line); + background: var(--wf-bg-2); + font-size: 14.5px; +} +.fr-deck .fr-n { + font-size: 1.05em; +} +.fr-total { + margin-left: auto; + display: inline-flex; + align-items: baseline; + gap: 5px; + color: var(--wf-tx-xlo); + font-size: 11px; + letter-spacing: 0.4px; + text-transform: uppercase; +} +.fr-total-n { + font-family: var(--wf-mono); + font-weight: 700; + font-size: 14px; + color: var(--wf-tx-lo); + font-variant-numeric: tabular-nums; + text-transform: none; +} + +/* ── Compact sidebar line (bottom of the conversation sidebar) ── */ +/* Deliberately discreet: a single footer-styled row (mirrors the Réglages row right + below it), the merged three-stage view, small type. `nowrap` + `hidden` keep it to + ONE line at every sidebar width rather than growing to two. The coloured numbers are + the only "colour" — no cap, no card. */ +.fr-side { + padding: 9px 12px; + border-top: 1px solid var(--wf-line); + font-size: 11px; + white-space: nowrap; + overflow: hidden; +} +.fr-side .fr-line { + flex-wrap: nowrap; + gap: 6px; +} +/* Tighten the number↔label gap in the narrow panel so all three stages clear the + 190px minimum width without clipping. */ +.fr-side .fr-seg { + gap: 4px; +} diff --git a/src/ui/conductor-flightdeck.css b/src/ui/conductor-flightdeck.css index adc6618..877a3ce 100644 --- a/src/ui/conductor-flightdeck.css +++ b/src/ui/conductor-flightdeck.css @@ -1,5 +1,5 @@ /* conductor-flightdeck.css — the FlightDeck (agent dashboard) view. - Cards per stream, grouped by repo, with a top attention bar. Ported from the + Cards per stream, grouped by repo, with the Fleet readout banner on top. Ported from the Conductor design handoff's view-agents.css (V1 "par dépôt, cartes"). The leaf bits (.wf-card, .wf-ask, .wf-act, .wf-ctxm, .wf-todobar, status dots/pills) come from the shared kit (conductor-wirekit.css); only the grid/layout lives @@ -9,10 +9,8 @@ .ag-page { flex:1; min-height:0; background:var(--wf-bg); } .ag-scroll { flex:1; min-height:0; overflow-y:auto; padding:6px 20px 20px; } -/* --- attention bar --- */ -.ag-attn { display:flex; align-items:center; gap:12px; padding:12px 20px; border-bottom:1px solid var(--wf-line); background:linear-gradient(90deg,var(--wf-att-bg),var(--wf-bg-2) 70%); } -.ag-attn.calm { background:var(--wf-bg-2); } -.ag-attn-chip { display:inline-flex; align-items:center; gap:7px; font-size:11.5px; color:var(--wf-tx); padding:5px 11px; border:1px solid var(--wf-line-2); border-radius:999px; background:var(--wf-panel); } +/* The top-of-deck banner is the shared Fleet readout (.fr-deck, conductor-fleet.css), + which replaced the old bespoke attention bar. */ /* --- repo group --- */ .ag-repo { padding-top:18px; } diff --git a/src/ui/conductor-wirekit.css b/src/ui/conductor-wirekit.css index c6aa125..4e7448d 100644 --- a/src/ui/conductor-wirekit.css +++ b/src/ui/conductor-wirekit.css @@ -112,10 +112,31 @@ deliberately distinct from green "running" (and calm: no pulse) so the two are never confused — present, waiting on background work, but not demanding attention. */ .wf-dot.bg { background:#a78bfa; box-shadow: 0 0 0 3px #a78bfa2e; } +/* "bgring" — a violet ring around the status dot, for a settled alert (review / + question / error) whose agent still has background work running: the review/blue (or + question/orange) core keeps its meaning, the violet ring adds "…and work continues". + Two-layer box-shadow (matches the approved option-B mock): the colour's OWN dark tint + as a thin separation ring first, then the violet — that separation is essential because + blue (#7aa2e3) and violet (#a78bfa) are close hues and would otherwise blend into one + indistinct blob. Per-colour rules (0,3,0 specificity) so each beats its .wf-dot.<colour> + base regardless of source order. */ +.wf-dot.wait.bgring { box-shadow: 0 0 0 2px var(--wf-wait-bg), 0 0 0 3.5px #a78bfa; } +.wf-dot.att.bgring { box-shadow: 0 0 0 2px var(--wf-att-bg), 0 0 0 3.5px #a78bfa; } +.wf-dot.err.bgring { box-shadow: 0 0 0 2px var(--wf-err-bg), 0 0 0 3.5px #a78bfa; } @keyframes wf-pulse { 0%,100%{ opacity:1 } 50%{ opacity:.35 } } .wf-dot.pulse { animation: wf-pulse 1.8s ease-in-out infinite; } +/* Violet "still working in the background" chip, appended to a review/question/error + status header (StateBlock) when the finished agent has background tools running. */ +.wf-bgchip { + display:inline-flex; align-items:center; gap:5px; margin-left:auto; + font-size:10px; letter-spacing:.2px; text-transform:none; font-weight:600; + color:#c9b8ff; background:#221a3c; border:1px solid #3d2f63; border-radius:999px; + padding:2px 8px; +} +.wf-bgchip .sp { width:6px; height:6px; border-radius:50%; background:#a78bfa; flex:0 0 auto; } + .wf-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; font-weight: 500; letter-spacing: .2px; diff --git a/src/ui/kit.tsx b/src/ui/kit.tsx index 953de61..e06bf69 100644 --- a/src/ui/kit.tsx +++ b/src/ui/kit.tsx @@ -183,10 +183,13 @@ export const WF_STATUS: Record<StreamState, { label: string; pill: string; dot: }; export const WF_ATTENTION: StreamState[] = ["ask", "err", "review"]; -export function Dot({ s, pulse }: { s: StreamState; pulse?: boolean }) { +export function Dot({ s, pulse, ring }: { s: StreamState; pulse?: boolean; ring?: boolean }) { const st = WF_STATUS[s]?.dot || "done"; const live = pulse && (s === "work" || s === "ask" || s === "err"); - return <span className={"wf-dot " + st + (live ? " pulse" : "")} />; + // `ring` = a violet outer ring on top of the status colour, for a settled alert + // (review / question / error) whose agent still has BACKGROUND work running — the + // "finished, but work continues" accent. See conductor-wirekit .wf-dot.bgring. + return <span className={"wf-dot " + st + (live ? " pulse" : "") + (ring ? " bgring" : "")} />; } /** A dedicated "this conversation is running" indicator with more presence than a From 3285e27569544a0afcf9c719b7af211efb3ac82e Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 14:04:10 +0200 Subject: [PATCH 13/91] chore(release): v0.24.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- CHANGELOG.md | 4 ++++ CLAUDE.md | 33 +++++++++++++++++++++------------ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecca81..e14dce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,5 +12,9 @@ sur la page GitHub — il n'apparaît pas dans l'app. ## v0.24.0 +- Flight Deck : cartes cliquables avec pop-ups (conversation, dernier message, to-do) et vue d'ensemble enrichie de la flotte d'agents. +- Alerte quand un agent a terminé, même s'il tournait en tâche de fond ; les notifications de tâches internes n'encombrent plus le fil. +- Bouton « + » du composer : joindre des fichiers et des images à un message. +- Page Réglages repensée, avec un récapitulatif des raccourcis clavier (et de nouveaux raccourcis). - Confirmation avant de supprimer une conversation **en cours d'exécution** (les conversations inactives se suppriment toujours en un clic, annulable avec ⌘Z). - Refonte de la page de mise à jour : nouveautés de version lisibles, et avertissement clair avant le redémarrage — avec le nombre de conversations en cours qui seront interrompues. diff --git a/CLAUDE.md b/CLAUDE.md index bf5b3cc..4e7a278 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,10 +112,11 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell - Spawn du binaire `claude` avec `--output-format stream-json --input-format stream-json --verbose --permission-prompt-tool stdio`. - Mode **bidirectionnel persistant** (un process vit toute la session) — PAS `claude -p` (one-shot). - Messages JSON-lines : `system`, `assistant`, `user`, `tool_use`, `tool_result`, `result`, `stream_event`. +- **Pièces jointes (images)** : le `content` d'un message `user` est un ARRAY de blocs → on y ajoute `{type:"image",source:{type:"base64",media_type,data}}` pour joindre une image. VÉRIFIÉ accepté par le binaire 2.1.187 (piloté en stdin avec un PNG → il « voit » l'image) ; **png/jpeg/gif/webp uniquement** ; le format `document`/`type:"file"` n'a PAS été retenu (non vérifié). `send_message` transporte `text` + `images: Vec<ImageAttachment>` (`transport::user_message_with_images`, texte puis blocs image, bloc texte vide omis). Bouton « + » du composeur (`composerAttachments.ts`, store en mémoire par conv, non persisté) : images → bloc base64 + vignette optimiste ; autres fichiers → mention de chemin ; collage Cmd+V → attachement (plafond 16 Mio, aligné `fs::MAX_FILE_BYTES`). Octets lus via `commands.readImage` (réutilisé du viewer). Reload : `history.rs` (`push_user`/`first_user_text`) posent un placeholder `[image]` pour un tour image-seule (vignettes NON re-rendues — le modèle de blocs normalisés ne porte pas encore l'image ; follow-up). - Canal de contrôle : `control_request` / `control_response` (sous-types : `initialize`, `can_use_tool`, `set_permission_mode`, `interrupt`, `mcp_message`, `generate_session_title`, `remote_control`, `stop_task`). - `SessionStatePayload` contient un champ **`cwd`** capté depuis `system/init` (ré-émis à chaque tour) — source de vérité du répertoire courant. Le cwd n'est PAS figé : l'agent peut le déplacer via outils worktree. - **Arrêt d'une tâche de fond** : wire = `control_request{subtype:"stop_task", task_id}` — le sous-type est **`stop_task`** (PAS `task_stop`, disséqué verbatim dans l'extension VS Code). -- **Remote control** : `control_request{subtype:"remote_control", enabled:bool, name?}` → `control_response{response:{session_url, connect_url}}` (doublement niché `response.response`). Bridge santé : `system/bridge_state{state:"disconnected"|"error"}` — **dégrade seulement**, "connected" ne vient QUE de la réponse au control_request. Spawn avec **`--replay-user-messages` inconditionnel** (sans lui le binaire n'émet aucune ligne `user` sur stdout) ; on estampille chaque message envoyé d'un uuid et on supprime l'écho de NOS propres tours (`assembler.sent_user_uuids`, one-shot) ; tour distant (uuid inconnu) surfacé. **Distinguer live vs historique est OBLIGATOIRE** : `history.rs` n'émet pas de `turn_result` → splicer l'historique regrouperait tous les tours user en haut (régression). +- **Remote control** : `control_request{subtype:"remote_control", enabled:bool, name?}` → `control_response{response:{session_url, connect_url}}` (doublement niché `response.response`). Bridge santé : `system/bridge_state{state:"disconnected"|"error"}` — **dégrade seulement**, "connected" ne vient QUE de la réponse au control_request. Spawn avec **`--replay-user-messages` inconditionnel** (sans lui le binaire n'émet aucune ligne `user` sur stdout) ; on estampille chaque message envoyé d'un uuid et on supprime l'écho de NOS propres tours (`assembler.sent_user_uuids`, one-shot — l'uuid est consommé quel que soit le contenu du tour, texte comme image-seule) ; tour distant (uuid inconnu) surfacé. **Distinguer live vs historique est OBLIGATOIRE** : `history.rs` n'émet pas de `turn_result` → splicer l'historique regrouperait tous les tours user en haut (régression). ## Tools IDE exposés à l'agent (Phase 2) @@ -127,16 +128,16 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell - `supervisor/` : `protocol.rs` (types serde + variantes typées pour `task_started/progress/updated/notification`), `transport.rs` (spawn + reader/writer/stderr), `control.rs` (canal de contrôle + permissions), `model.rs` (normalisation UI + `BackgroundTask`, `WorkflowRun/Phase/Journal`), `assembler.rs` (map `background_tasks` keyée `task_id` ; classification Bash vs Monitor vs Agent depuis nom de tool capté dès `content_block_start`), `session.rs` (acteur tokio par session), `subagents.rs` (lecteurs disque artefacts tâches de fond — **multi-slug** via `session_dirs` car une session qui déplace son cwd éclate ses artefacts sur plusieurs slugs), `history.rs` (`parse_transcript_str(skip_sidechain)` réutilisé par `subagents.rs` et `load_persisted_state`). - `store/` : `model.rs` (records de domaine) + `db.rs` (struct `Store` = SEUL service SQL). DB : `app_data_dir()/tosse.db`, WAL, `foreign_keys ON`. Migrations versionnées : `SCHEMA_VERSION` + runner append-only, corps **idempotents** (`CREATE TABLE IF NOT EXISTS`, helper `add_column_if_absent`). `wipe_all()` = escape hatch manuel uniquement (bouton « Tout supprimer » dans les Réglages). - `git/` : `src-tauri/src/git/mod.rs` = SEUL service git. Enveloppe le **binaire `git` en CLI** (PAS la crate git2 — sécurité de suppression déléguée à `git worktree remove`, parsing porcelain stable, pas de dépendance libgit2). - - `fs/` : `src-tauri/src/fs/mod.rs` = SEUL service filesystem de l'éditeur. `read_dir`, `read_file` (garde binaire/trop-gros >2 Mio), `write_file`, `FsWatcher` (notify, debounce ~150 ms, filtre `.git/node_modules/target/dist/build/…`). + - `fs/` : `src-tauri/src/fs/mod.rs` = SEUL service filesystem de l'éditeur. `read_dir`, `read_file` (garde binaire/trop-gros >2 Mio), `write_file`, `read_image` (base64 des octets, garde `MAX_FILE_BYTES` 16 Mio — réutilisé par le viewer ET l'attachement d'images du composeur), `FsWatcher` (notify, debounce ~150 ms, filtre `.git/node_modules/target/dist/build/…`). - `usage/` : `src-tauri/src/usage/mod.rs` = SEUL service credentials OAuth + endpoint d'usage. `GET https://api.anthropic.com/api/oauth/usage` (% forfait 5h/7j). Token depuis `~/.claude/.credentials.json` → Keychain macOS (`/usr/bin/security`). **Lecture seule** (jamais de refresh ni d'écriture de credentials). - `terminal/` : `src-tauri/src/terminal/mod.rs` = SEUL service PTY. Invariants à ne PAS régresser : (1) writer PTY **hors du lock global** (anti-hang au quit) ; (2) teardown tue le GROUPE de process (`kill(-pid, SIGKILL)`, shell = leader `setsid`) — anti-orphelins ; (3) instances xterm **persistantes par conversation** (survivent fermeture panneau + switch conv), gérées par `termManager.ts` hors React ; (4) Monaco + TerminalView **lazy-loadés** (code-split, hors bundle de démarrage) ; (5) nettoyage xterm câblé via `cleanup.ts` sur `removeConversation`/`removeRepo`/`wipeAllData`. - - Surface IPC (tauri-specta) : `spawn_session`, `send_message`, `answer_permission`, `set_permission_mode`, `interrupt_session`, `stop_session`, `stop_task`, `load_persisted_state`, `upsert_repo`, `delete_repo`, `upsert_conversation`, `delete_conversation`, `set_active_conversation`, `wipe_all_data`, `list_worktrees`, `worktree_status`, `create_worktree`, `remove_worktree`, `read_dir`, `read_file`, `write_file`, `watch_dir`, `unwatch_dir`, `load_subagent_transcript`, `load_workflow_run`, `load_workflow_journal`, `load_workflow_phases`, `read_task_output`, `request_user_attention(critical:bool)`, `get_plan_usage`, `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `generate_conversation_title`, `set_remote_control`. Events : `session_state`, `session_message`, `session_permission`, `FsChangeEvent`, `FsWatchErrorEvent`, `session_task`, `TerminalOutputEvent`, `TerminalExitEvent`, `SessionTitleEvent`, `SessionRemoteControlEvent`. + - Surface IPC (tauri-specta) : `spawn_session`, `send_message` (text + `images: Vec<ImageAttachment>` base64), `answer_permission`, `set_permission_mode`, `interrupt_session`, `stop_session`, `stop_task`, `load_persisted_state`, `upsert_repo`, `delete_repo`, `upsert_conversation`, `delete_conversation`, `set_active_conversation`, `wipe_all_data`, `list_worktrees`, `worktree_status`, `create_worktree`, `remove_worktree`, `read_dir`, `read_file`, `read_image`, `write_file`, `watch_dir`, `unwatch_dir`, `load_subagent_transcript`, `load_workflow_run`, `load_workflow_journal`, `load_workflow_phases`, `read_task_output`, `request_user_attention(critical:bool)`, `get_plan_usage`, `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `generate_conversation_title`, `set_remote_control`. Events : `session_state`, `session_message`, `session_permission`, `FsChangeEvent`, `FsWatchErrorEvent`, `session_task`, `TerminalOutputEvent`, `TerminalExitEvent`, `SessionTitleEvent`, `SessionRemoteControlEvent`. - `src/` (React) : `features/{flightdeck,conversation,editor,terminal,git,explorer,settings}`, `ipc/`, `store/`, `agent/`, `notifications/`, `ui/` - - `store/` clés : `conversationsStore.ts` (groupement par repo), `backgroundTasksStore.ts` (registre tâches de fond + sélecteurs Bash/Monitor/Workflow, running-first), `workflowLive.ts` (accumulation `task_progress` par phase), `planUsage.ts` (TanStack Query, poll 5 min), `notifications.ts` (3 prefs localStorage), `display.ts` (`cleanOutput` + `markdownMode`, défaut `warm`), `workFold.ts` (état déplié/replié clean-output par conv, localStorage), `remoteControl.ts` (état live-only, keyé convId), `commandsStore.ts` (catalogue slash-commands caché par cwd, localStorage), `updater.ts` (auto-check lancement + 2h), `appErrors.ts` (bannière erreurs systémiques déduplicatées). - - `agent/` : `status.ts` (statut agent), `fleet.ts` (ordonnancement flotte), `ask.ts` (classification permission), `subagentMeta.ts` (`isRunInBackground`, `isDetachedAgentAck` (≥2 marqueurs), `runIdFromResult`). + - `store/` clés : `conversationsStore.ts` (groupement par repo), `backgroundTasksStore.ts` (registre tâches de fond + sélecteurs Bash/Monitor/Workflow, running-first ; `useRunningTaskCount` par conv + `runningCountsByConv`/`useRunningCountsByConv` = counts par conv pour l'agrégat flotte), `workflowLive.ts` (accumulation `task_progress` par phase), `planUsage.ts` (TanStack Query, poll 5 min), `notifications.ts` (3 prefs localStorage), `display.ts` (prefs d'affichage localStorage `tosse:display` : `cleanOutput`, `markdownMode` défaut `warm`, `fleetBannerFlightDeck`/`fleetBannerConversation` ON, `alertOnBackgroundWait` ON, `showTaskNotifications` OFF), `workFold.ts` (état déplié/replié clean-output par conv, localStorage), `remoteControl.ts` (état live-only, keyé convId), `commandsStore.ts` (catalogue slash-commands caché par cwd, localStorage), `updater.ts` (auto-check lancement + 2h ; `inAppReleaseNotes` = notes affichées in-app, split sur `<!-- gh-only -->`), `appErrors.ts` (bannière erreurs systémiques déduplicatées). Attachements d'images du composeur : `features/conversation/composerAttachments.ts` (store en mémoire par conv, NON persisté ; nettoyé sur removeConversation/removeRepo/wipeAllData comme composerDrafts). + - `agent/` : `status.ts` (statut agent — `isActivelyRunning` gate la confirmation de suppression ; `readoutBucket` = les 8 `kind` → 4 stages du fleet readout ; `bg?`/`backgroundCount()` sur review/needInput/error = « fini mais tâche de fond en cours » ; `deriveAgentStatus` prend `alertWhileBackgrounding`), `fleet.ts` (ordonnancement flotte + fleet readout : `tallyFleet`/`useFleetCounts`/`fleetSegments`/`mergedFleetSegments`/`isFleetCalm` — l'agrégat reçoit désormais les tâches de fond par conv via `runningCountsByConv`, corrige un trou où le backgrounding était ignoré), `ask.ts` (classification permission), `subagentMeta.ts` (`isRunInBackground`, `isDetachedAgentAck` (≥2 marqueurs), `runIdFromResult`). - `notifications/` : `notify.ts`, `sound.ts` (carillon Web Audio, zéro asset), `transition.ts` (`agentEventFor` — point UNIQUE de détection des transitions d'état agent). - - `src/ui/` : `Toggle.tsx`, `kit.tsx` (`ContextMeter`, `TodoPips`, `ChipBtn`), `shortcuts.ts` (helpers purs, robustesse AZERTY). + - `src/ui/` : `Toggle.tsx`, `kit.tsx` (`ContextMeter`, `TodoPips`, `ChipBtn`, `Dot` avec prop `ring`), `ConfirmDialog.tsx` (modale oui/non réutilisable, props `danger`/`busy`, portal), `shortcuts.ts` (helpers purs robustesse AZERTY + registre `ACTION_BINDINGS`/`matchChord` + catalogue d'affichage `SHORTCUT_GROUPS`). - `packages/ipc-types/` (types générés Rust→TS, à committer avant PR) @@ -170,6 +171,9 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell - État éditeur par conversation en mémoire ; layout (`terminalOpen`, `terminalFraction`) persisté en localStorage `tosse:editor` — PAS en SQLite. - Politique fichier ouvert : buffer propre → reload live ; buffer sale → garde modifs + bandeau « modifié sur le disque ». Autosave debounced + Cmd+S. - Raccourcis clavier : **chiffres** (⌘1/⌘2) → `e.code` (robustesse AZERTY — les chiffres en Shift renverraient un symbole sur `e.key`) ; **lettres** (⌘Z) → `e.key` (robustesse AZERTY — `e.code` désignerait la position QWERTY). ⌘Z bail si focus dans zone à undo propre (input/textarea/contenteditable, Monaco `.monaco-editor`, xterm `.xterm`). +- **Registre de raccourcis (source unique)** : au-delà des chords historiques câblés à la main dans `App.tsx` (⌘1/⌘2 vue, ⌘Z undo, ⌘⇧M son, ⌘, réglages), les raccourcis d'action sont pilotés par une table `ACTION_BINDINGS` + le matcher générique `matchChord(e, spec)` (`src/ui/shortcuts.ts`), dispatchés dans le handler global de `App.tsx` (via `dispatchAction`, lecture store live, pas de closure périmée) ET documentés par le MÊME catalogue `SHORTCUT_GROUPS` dans Réglages → onglet « Raccourcis » (`ShortcutsSection.tsx`) → zéro désync doc/comportement. Nouveaux raccourcis : ⌘B éditeur, ⌘J terminal, ⌘⇧G Git, ⌘L clean output, ⌘E extensions (portée « conversation » : inertes hors vue conversation) ; ⌘N nouvelle conv, ⌘⌥↑/↓ conv précédente/suivante, ⌘⇧O historique (globaux). AZERTY : lettres via `e.key`, flèches via `e.code`. Globaux = l'emportent sur l'éditeur (convention VS Code). +- **Suppression de conversation** : friction-free (× en un clic, annulable ⌘Z) SAUF si la conversation est activement en cours (`isActivelyRunning` : tour en flight ou tâches de fond) → `ConfirmDialog` avant de tuer la session live (`ConvRow` dans `ConductorSidebar.tsx`). Les états inactifs (idle/review/needInput…) gardent le clic unique. +- **Page Réglages** (`features/settings/`) : modale à rail d'onglets (Général / Conversation / Raccourcis / Notifications / Mises à jour / Données). Sections composées de briques réutilisables `SettingsKit.tsx` (`PageHead` = titre + sous-titre d'onglet ; `SettingsGroup` = carte titrée à icône coral ; `ToggleRow` = ligne titre+hint+contrôle) → toutes les sections partagent le même style (groupes en **cartes** arrondies, onglet actif à **accent coral** + barre latérale, hero « À propos »). CSS module `SettingsPanel.module.css`. **Sous-agents & tâches de fond** - Classification producteur (Bash vs Monitor vs Agent) : capté côté Rust dès `content_block_start` par nom de tool. @@ -180,14 +184,15 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell **Rendu & affichage** - **Surfaçage d'erreur unifié** : toute erreur visible dans la vue conversation (`ConversationItem::Notice` + `addErrorTurn`). Subtypes normalisés : `control_error`, `process_exited`, `send_failed`, `protocol_error`, `permission_error`, `history_error`, `error`. -- **Transitions d'état agent** : point UNIQUE dans `useGlobalSessionEvents.ts` via `agentEventFor` (`transition.ts`, fonction pure). Règles : `awaiting_permission` false→true = attention ; `busy` true→false (vivant) = terminé. **Ne PAS dupliquer cette détection**. -- **Mode Clean output** (`cleanOutput`, localStorage `tosse:display`) : repli du travail intermédiaire par round. Invariant liveness via `atomStillRunning` : si un `BackgroundTask` existe pour le tool_use, SON statut fait autorité (`running` → reste visible) ; sinon fallback `!tool_result`. **NE PAS keyer la complétion d'un sous-agent sur le seul `tool_result`** (peut arriver avant le `task_notification` terminal → fold/carte incohérents). -- **Garde `<task-notification>`** : le parser se déclenche UNIQUEMENT si le texte trimmé **OUVRE** sur `<task-notification>` (anti-faux-positif — un prompt qui mentionne le tag ne doit pas déclencher le rendu). -- **Invocation de skill/slash-command** : expansée en messages `role:user` (pas de type dédié). Le body SKILL.md porte `isMeta:true` → **droppé** (`assembler.rs::ingest_user` + `history.rs::push_user`), jamais de bulle user. Un skill **model-invoqué** (`tool_use{name:"Skill"}`) rend un **chip commande dédié** (`SkillChip` + segment `skill` dans `toolGroup.ts`, calqué sur agent/workflow — casse le run, non compté comme travail) ; la voie composer (`parseSlashCommand`, header `<command-name>`) reste inchangée. Wire : `docs/claude-code-protocol.md` §3.7.1. -- **Rendu Markdown** : 3 modes (Classic/Warm/Minimal, défaut `warm`), posé via `data-md-mode` sur `.md-body` → variantes CSS dans `conductor-markdown-modes.css`. Coloration syntaxique : `highlight.js` importé lazy, **langages taggés ET connus seulement** (pas de `highlightAuto` — perf + zéro fausse détection). Chip de chemin (`FileMention.tsx`) : rendu segmenté réservé aux VRAIS fichiers (gating `target != null || demo`). +- **Transitions d'état agent** : point UNIQUE dans `useGlobalSessionEvents.ts` via `agentEventFor` (`transition.ts`, fonction pure). Règles : `awaiting_permission` false→true = attention ; `busy` true→false (vivant) = terminé. **Ne PAS dupliquer cette détection**. ⚠️ Le ping OS « terminé » est SUPPRIMÉ quand le statut résultant est `backgrounding` (fini proprement + tâche de fond en cours + réglage `alertOnBackgroundWait` OFF) — gating sur le statut dérivé, pas de branchement ad hoc dans `notifyTransition`. +- **Fleet readout & alerte « terminé + tâche de fond »** : `ui/FleetReadout.tsx` (+ `conductor-fleet.css`) = ligne adaptative des compteurs d'agents par stage (Running · Review · Need Attention · Idle), chiffres colorés, zéros masqués, activité d'abord, « Fleet rests · N Idle » au repos, portée = TOUTE la flotte. Deux placements : bandeau large en haut du Flight Deck (a **REMPLACÉ** l'ancien `AttentionBar`) + encadré compact 1 ligne en bas de la sidebar Conversation (fusionne Review+Need Attention en un seul « Attention » → 3 stages, pour tenir sur une ligne même à la largeur mini 190px). Réglages : 2 toggles séparés (`fleetBanner*`). — Un agent qui finit son tour alors qu'une tâche de fond tourne encore reçoit un **accent violet** sur son review/question (pour le distinguer d'un vrai fini) : anneau violet `.wf-dot.<wait|att|err>.bgring` (box-shadow 2 couches = tint de la couleur + violet ; la SÉPARATION sombre est indispensable, sinon bleu et violet fusionnent) + chip « N en fond » dans la carte Flight Deck (`StateBlock`). Réglage `alertOnBackgroundWait` (défaut ON) : OFF → l'agent passe directement en `backgrounding` (violet calme) sans alerte, le review ne surface qu'une fois la tâche de fond aussi terminée ; une question / une erreur alertent toujours. +- **Mode Clean output** (`cleanOutput`, localStorage `tosse:display`) : repli du travail intermédiaire par round. Invariant liveness via `atomStillRunning` : si un `BackgroundTask` existe pour le tool_use, SON statut fait autorité (`running` → reste visible) ; sinon fallback `!tool_result`. **NE PAS keyer la complétion d'un sous-agent sur le seul `tool_result`** (peut arriver avant le `task_notification` terminal → fold/carte incohérents). Un tour user injecté mid-travail (`InlineUserMarker`) rend aussi ses images jointes. +- **Garde `<task-notification>`** : le parser (`parseSpecialMessage`) se déclenche UNIQUEMENT si le texte trimmé **OUVRE** sur `<task-notification>` (anti-faux-positif — un prompt qui mentionne le tag ne doit pas déclencher le rendu). De plus, le rendu (`SpecialMessageCard`) est **MASQUÉ PAR DÉFAUT** (pref `showTaskNotifications` OFF) car ces messages injectés par le CLI polluent le fil au reload / import depuis l'historique. Gate UNIQUE dans `SpecialMessageCard` → couvre les 3 surfaces (`MsgUser`, `InlineUserMarker` clean-output, `SubAgentTranscript`) ; le composant de rendu propre (`TaskNotificationCard`) est CONSERVÉ intact — flip le toggle Réglages pour les réafficher. +- **Invocation de skill/slash-command** : expansée en messages `role:user` (pas de type dédié). Le body SKILL.md est **droppé** (jamais de bulle user) : **au reload** via `isMeta:true` (`history.rs::push_user`), mais **en live** le CLI **OMET `isMeta`** sur le wire (ligne nue) → drop via le prefix `Base directory for this skill:` tant qu'un `tool_use` Skill est armé (`assembler.rs`, champ `skill_invocation_pending`, désarmé à `ingest_result` ; double garde prefix+flag → jamais de faux-drop d'un vrai tour). Fixtures `capture_skill.jsonl` (disque, avec isMeta) + `capture_skill_live.jsonl` (live, sans isMeta). Un skill **model-invoqué** (`tool_use{name:"Skill"}`) rend un **chip commande dédié** (`SkillChip` + segment `skill` dans `toolGroup.ts`, calqué sur agent/workflow — casse le run, non compté comme travail) ; la voie composer (`parseSlashCommand`, header `<command-name>`) reste inchangée. Wire : `docs/claude-code-protocol.md` §3.7.1. +- **Rendu Markdown** : 3 modes (Classic/Warm/Minimal, défaut `warm`), posé via `data-md-mode` sur `.md-body` → variantes CSS dans `conductor-markdown-modes.css`. Composant réutilisable `StreamMarkdown` (`src/features/conversation/StreamMarkdown.tsx`, react-markdown + remark-gfm) — réutilisé hors conversation (ex. notes de mise à jour dans les Réglages). Coloration syntaxique : `highlight.js` importé lazy, **langages taggés ET connus seulement** (pas de `highlightAuto` — perf + zéro fausse détection). Chip de chemin (`FileMention.tsx`) : rendu segmenté réservé aux VRAIS fichiers (gating `target != null || demo`). - **Bindings IPC** (`src/ipc/bindings.ts`) : générés par tauri-specta → **toujours regénérer et committer avant PR** (le build release ne regénère pas). - **Flight Deck — reply modal** (`FlightDeckReplyModal` + `flightdeckModalStore`) : répondre à une conversation en modale par-dessus le Flight Deck sans quitter la vue. Réutilise `ConversationPane` verbatim (3ᵉ point de montage, keyé id stable) SANS `SidePanel` (léger, par design). Store keyé id stable, INDÉPENDANT de l'activeId ; modale montée dans `App`, **gated sur la vue flightdeck** + fermée par un effet en quittant la vue → jamais la même conv montée en modale ET plein écran. Clics : TITRE de carte = plein écran, TOUT bouton d'action (« Vu » excepté) = modale, bouton « Plein écran » promeut. `FileMentionProvider` gagne un flag `inert` (propagé via `ConversationPane.inertMentions`) : dans la modale (pas d'éditeur monté) les chemins de fichiers sont du texte simple — sinon clic mort + flip silencieux du flag persisté `editorOpen`. `notify.ts` : la conv ouverte en modale compte comme « regardée » (via `useFlightdeckModal.getState().convId`) → pas de notif OS redondante. -- **Le calque le plus haut réclame Échap** : un listener Échap au niveau `window` (ex. la reply modal) double-ferme avec les popovers drill-in `document` (`TranscriptPopover` / `TaskOutputPopover` / `WorkflowDetail`) car keydown bulle document→window. Convention : les popovers font `preventDefault()` sur leur Échap (ils possèdent la touche quand ils sont ouverts) et le calque externe garde `if (!e.defaultPrevented)` avant de fermer → un Échap ne ferme qu'UNE couche. Les menus en capture-phase + `stopPropagation` (slash-menu du composer, ReviewBar) gagnent déjà. +- **Échap : garde plein écran globale + « une touche = une couche »** : une garde en **phase CAPTURE** au niveau `window` (`App.tsx`) fait `preventDefault(Échap)` **INCONDITIONNELLEMENT** (sauf focus dans Monaco `.monaco-editor` / xterm `.xterm`) — c'est la SEULE autorité qui empêche macOS de sortir du **plein écran natif** (comportement par défaut quand un keydown atteint AppKit non consommé). ⚠️ Un `preventDefault` en phase BUBBLE ne suffit PAS à bloquer WKWebView (constaté : Extensions le faisait, ça sortait quand même) — d'où la phase capture. ⚠️ Fix JS **à reconfirmer sur build** ; si un cas sort encore du plein écran → interception côté Rust/Tauri (fenêtre native). Comme cette garde consomme TOUJOURS Échap, on n'utilise PLUS `!e.defaultPrevented` pour éviter la double-fermeture : les popovers drill-in `document` (`TranscriptPopover` / `TaskOutputPopover` / `WorkflowDetail` / badge tâches de fond `BackgroundTaskBadge`) font **`stopPropagation()`** sur leur Échap → le calque `window` externe (reply modal, Extensions) ne reçoit tout simplement pas la touche quand un popover interne la possède → un Échap ne ferme qu'UNE couche. `ExtensionsManager` + `FlightDeckReplyModal` ne gatent donc PLUS sur `defaultPrevented` (ils ferment sur Échap tout court). Les menus capture-phase + `stopPropagation` (slash-menu du composer, ReviewBar) restent inchangés. ## Commandes dev @@ -211,6 +216,7 @@ Alexandre **dogfoode** l'app de production (`/Applications/Tosse Code.app`, iden **Règle** : donner un nom + identifiant DISTINCTS à tout build de test. - Override au build : `tauri build --config` (overlay JSON `productName`/`identifier`), SANS modifier `tauri.conf.json` committé (qui reste la config prod). - Fichier de référence : `src-tauri/dev-build.conf.json` (`productName` "Tosse Code dev build", `identifier` `com.tosse.desktop.dev`). +- ⚠️ `open` sur une app DÉJÀ lancée ne recharge PAS le nouveau binaire (il ne fait que la ramener au premier plan) → après un rebuild, tuer l'instance en cours (`pkill -f "<productName>"`) puis `open -n` pour charger le binaire fraîchement buildé. ## Branches & gouvernance @@ -233,6 +239,8 @@ SemVer `MAJEUR.MINEUR.CORRECTIF`. En `0.y.z` : MINEUR pour toute nouveauté, COR **Release** : workflow `.github/workflows/release.yml`, 100 % manuel (`workflow_dispatch`), à lancer depuis `main`. Bundle macOS universel (Apple Silicon + Intel), publication directe (`releaseDraft: false`). Assets : `.dmg`, `.app.tar.gz` + `.sig`, `latest.json`. Seul `Alex375` peut déclencher (job `authorize`). Garde-fou anti-doublon : refus si version déjà releasée. +**Notes de release / CHANGELOG** : le corps de la release est composé par `release.yml` (étape « Composer les notes de release ») = section `## vX.Y.Z` de `CHANGELOG.md` (racine) + marqueur `<!-- gh-only -->` + note d'installation Gatekeeper (`.github/release-install-note.md`). Le `CHANGELOG.md` est rempli par le skill `/release` au moment du bump (étape 3b : quelques puces courtes orientées utilisateur, déduites des commits `<dernier tag>..HEAD`, `## vX.Y.Z` avec le `v` obligatoire). Repli générique dans `release.yml` si la section manque → une release n'est jamais bloquée pour ça. L'app N'affiche QUE la partie AVANT `<!-- gh-only -->`, rendue en Markdown (`inAppReleaseNotes`, `src/store/updater.ts`) ; la note d'installation reste seulement sur la page GitHub (utile pour un `.dmg` téléchargé à la main). + **Signature macOS (self-signed)** : - But : DR stable (`identifier "com.tosse.desktop" and certificate leaf = H"…"`) → TCC conserve les autorisations de dossier d'une version à l'autre sans les redemander. - Certificat « Tosse Code Self-Signed » (~20 ans), sauvegardé dans `~/TosseCodeSigning.p12`. @@ -245,6 +253,7 @@ SemVer `MAJEUR.MINEUR.CORRECTIF`. En `0.y.z` : MINEUR pour toute nouveauté, COR - Check au lancement + toutes les 2h → download → vérif signature → `relaunch()`. - Clé privée : secret repo `TAURI_SIGNING_PRIVATE_KEY` + backup local `~/.tauri/tosse-code-updater.key` (sans mot de passe). **NE PAS la perdre** (sinon plus aucune MAJ signable). - `bundle.createUpdaterArtifacts: true`. MAJ touche uniquement le bundle `.app` ; données (SQLite + transcripts) hors bundle, préservées. +- **Page « Mise à jour »** (`src/features/settings/UpdateSection.tsx`) : nouveautés rendues en Markdown (`StreamMarkdown`), hero version courante → nouvelle version, PAS de blabla d'installation (cf. split `<!-- gh-only -->`). « Mettre à jour et redémarrer » passe **TOUJOURS** par une `ConfirmDialog` (l'app redémarre → toute session `claude` live est interrompue), renforcée (compte de conversations en cours + « Attendre ») quand des sessions tournent. Bannière globale `UpdateBanner.tsx`. ## IDs MCP (entités TOSSE associées à ce repo) diff --git a/package.json b/package.json index bbffef1..787b878 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tosse-code", - "version": "0.23.0", + "version": "0.24.0", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7476833..6ecc685 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4713,7 +4713,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tosse-code" -version = "0.23.0" +version = "0.24.0" dependencies = [ "base64 0.22.1", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b071d09..53c6ad1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tosse-code" -version = "0.23.0" +version = "0.24.0" description = "Tosse Code — desktop app to pilot Claude Code" authors = ["Tosse"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 343e354..6810355 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Tosse Code", - "version": "0.23.0", + "version": "0.24.0", "identifier": "com.tosse.desktop", "build": { "beforeDevCommand": "pnpm dev", From 2e4f1fb9350849aa47133bcd31b60626e68c33d5 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 14:13:15 +0200 Subject: [PATCH 14/91] =?UTF-8?q?docs(release):=20interdit=20squash/rebase?= =?UTF-8?q?=20merge=20+=20=C3=A9tape=20re-sync=20main=E2=86=92dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause racine des conflits de la PR de release : la PR #29 (v0.23.0) a été squash-mergée dans main, cassant le lien d'historique avec dev (branche permanente). La base de merge est restée figée → tout le changeset v0.23.0 est entré en conflit avec lui-même sur la release suivante. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .claude/skills/release/SKILL.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 0a1d498..fe4fe8b 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -122,6 +122,22 @@ gh pr merge <pr-number> --merge (En tant qu'admin avec `enforce_admins=false`, Alexandre peut finaliser le merge même si une protection résiste.) +⚠️ **TOUJOURS `--merge` (merge commit à deux parents). JAMAIS `--squash` ni `--rebase`, et JAMAIS le bouton « Squash »/« Rebase » de l'UI GitHub.** `dev` est une branche **permanente** re-mergée dans `main` à chaque release : un squash/rebase crée sur `main` un commit orphelin sans lien avec l'historique de `dev` → la base de merge reste figée à la release précédente → **la release SUIVANTE se retrouve avec des conflits sur tout le changeset** (le même diff vu « des deux côtés »). Un vrai merge commit fait avancer la base de merge et évite définitivement ce piège. (Idéalement le repo n'autorise QUE `allow_merge_commit` — `allow_squash_merge`/`allow_rebase_merge` à `false`.) + +## Étape 9b — Re-synchroniser `main → dev` (anti-divergence) + +Le merge commit de l'étape 9 vit sur `main` mais pas encore sur `dev` → ceinture-et-bretelles, on le ramène tout de suite pour que les deux branches restent alignées : + +```bash +git checkout dev +git fetch origin +git merge --ff-only origin/dev # dev local à jour +git merge origin/main # ramène le merge commit de release ; doit être propre +git push origin dev +``` + +Ce merge doit être **propre** (aucun conflit) puisque `main` ne contient que ce que `dev` a produit. S'il y a le moindre conflit, c'est le symptôme du piège squash décrit à l'étape 9 : **arrête-toi et analyse** (probable squash/rebase antérieur à réconcilier via `git merge -s ours origin/main`) plutôt que de résoudre à l'aveugle. + ## Étape 10 — Déclencher la release ```bash From 453bd5d8ab11713144c4e0f1b0edea87760da7c4 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 14:42:36 +0200 Subject: [PATCH 15/91] fix(review): corrige les 5 findings de la review de la PR #30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review multi-agents adversariale (8 finders + vérif finding-par-finding). Medium — régression Échap (introduite par le même PR) - SettingsPanel: retire le gate `!e.defaultPrevented` (revenu à `!busy`). Le garde Échap app-wide en phase capture (App.tsx) preventDefault TOUJOURS → gater dessus rendait la modale Réglages infermable au clavier. - ConfirmDialog: Échap passe de preventDefault à stopPropagation → aligné sur la convention « couche imbriquée = stopPropagation », un Échap ne ferme qu'une couche (le confirm interne, pas aussi le panneau Réglages). Low - ConductorComposer.addPaths: encadre les lectures d'images du sélecteur « + » dans le même send-lock `attaching` que le collage → un pick-then-Enter rapide ne part plus sans l'image (qui voyageait sur le message suivant). - ConductorComposer.addPaths: accumule les échecs d'attachement multiples en un seul message (dédupliqué) au lieu de n'afficher que le dernier. - PlanCard: ajoute stopPropagation sur l'Échap du composeur de commentaire de plan → n'annule que le brouillon, ne ferme plus aussi la reply-modal du Flight Deck (« une touche = une couche »). - fleet.ts: corrige 2 références @link mortes (FleetAttention/useFleetAttention renommés → useFleetLanes ; suppression du lien vers le type disparu). tsc vert · vitest 674/674. Front-only, aucun changement Rust. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/agent/fleet.ts | 10 ++++---- .../conversation/ConductorComposer.tsx | 25 +++++++++++++------ src/features/conversation/PlanCard.tsx | 4 +++ src/features/settings/SettingsPanel.tsx | 10 +++++--- src/ui/ConfirmDialog.tsx | 12 +++++---- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/agent/fleet.ts b/src/agent/fleet.ts index e1ff0b6..7ed04bd 100644 --- a/src/agent/fleet.ts +++ b/src/agent/fleet.ts @@ -26,10 +26,10 @@ import { agentStatusForEntry } from "./useAgentStatus"; // ---- Fleet readout (the "N Running · N Review · …" banner) ---------------------- /** - * The four fleet-readout stage counts, plus the fleet total. Coarser than - * {@link FleetAttention}: it also carries `running` and `idle`, so the readout banner - * (sidebar + FlightDeck top) can render every stage, not just the ones demanding - * attention. Each conversation lands in exactly one bucket via {@link readoutBucket}, + * The four fleet-readout stage counts, plus the fleet total. Carries `running` and + * `idle` alongside the attention stages, so the readout banner (sidebar + FlightDeck + * top) can render every stage, not just the ones demanding attention. Each conversation + * lands in exactly one bucket via {@link readoutBucket}, * so `running + review + needAttention + idle === total`. */ export interface FleetCounts { @@ -102,7 +102,7 @@ export function isFleetCalm(c: FleetCounts): boolean { /** * Live readout counts across every conversation — the same derive-per-conversation - * path as {@link useFleetAttention}, tallied into the four stages instead. `useShallow` + * path as {@link useFleetLanes}, tallied into the four stages instead. `useShallow` * over the counts means the banner only re-renders when a count actually moves. */ export function useFleetCounts(): FleetCounts { diff --git a/src/features/conversation/ConductorComposer.tsx b/src/features/conversation/ConductorComposer.tsx index 6ccfe58..8c2425e 100644 --- a/src/features/conversation/ConductorComposer.tsx +++ b/src/features/conversation/ConductorComposer.tsx @@ -388,15 +388,26 @@ export const ConductorComposer = forwardRef< // a path mention Claude reads with its own tools. const addPaths = async (paths: string[]) => { const mentions: string[] = []; - for (const p of paths) { - if (wireImageMimeForPath(p)) { - const res = await attachmentFromPath(p); - if (res && "error" in res) setAttachErr(res.error); - else if (res) useComposerAttachments.getState().add(session, res); - } else { - mentions.push(p); + const errs: string[] = []; + // Same send-lock as onPaste: these picked-image reads are async (disk + base64 over + // IPC, up to 16 MiB), so block send while they're in flight — else a fast + // pick-then-Enter sends BEFORE the image lands (it would ride the NEXT message). + setAttaching((n) => n + 1); + try { + for (const p of paths) { + if (wireImageMimeForPath(p)) { + const res = await attachmentFromPath(p); + if (res && "error" in res) errs.push(res.error); + else if (res) useComposerAttachments.getState().add(session, res); + } else { + mentions.push(p); + } } + } finally { + setAttaching((n) => Math.max(0, n - 1)); } + // Surface every failure at once — a later failure must not silently erase earlier ones. + if (errs.length) setAttachErr([...new Set(errs)].join(" · ")); insertMentions(mentions); }; diff --git a/src/features/conversation/PlanCard.tsx b/src/features/conversation/PlanCard.tsx index a1b08e6..3ea904c 100644 --- a/src/features/conversation/PlanCard.tsx +++ b/src/features/conversation/PlanCard.tsx @@ -496,7 +496,11 @@ export function PlanCard({ onChange={(e) => setDraftText(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { + // stopPropagation so this Escape only cancels the comment draft — it + // must not also bubble to a window-level closer (e.g. the Flight Deck + // reply modal), per the "une touche = une couche" convention. e.preventDefault(); + e.stopPropagation(); clearDraft(); } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index cf64d75..ee3b309 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -43,13 +43,15 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => .catch(() => setVersion(null)); }, [open]); - // Close on Escape, but never mid-wipe, and never when a higher layer already - // consumed it (a ConfirmDialog mounted inside — e.g. the update relaunch confirm — - // calls preventDefault so its Escape doesn't ALSO tear down the whole panel). + // Close on Escape, but never mid-wipe. The app-wide capture guard (App.tsx) always + // preventDefaults Escape, so gating on `defaultPrevented` here would mean the panel + // NEVER closes — that signal is now the guard's, not a "higher layer consumed it" + // marker. One-Escape-one-layer is upheld instead by any ConfirmDialog mounted inside + // calling stopPropagation, so its Escape never reaches this window-level handler. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy && !e.defaultPrevented) close(); + if (e.key === "Escape" && !busy) close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); diff --git a/src/ui/ConfirmDialog.tsx b/src/ui/ConfirmDialog.tsx index 768c8cf..d8dae6c 100644 --- a/src/ui/ConfirmDialog.tsx +++ b/src/ui/ConfirmDialog.tsx @@ -39,12 +39,14 @@ export function ConfirmDialog({ useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { - // Own Escape while open: preventDefault so an outer layer whose own Escape - // handler checks `!e.defaultPrevented` (e.g. the Settings panel closing on - // Escape) doesn't ALSO fire — one Escape closes exactly one layer (the repo's - // "topmost layer claims Escape" convention). + // Own Escape while open: stopPropagation so an outer window-level Escape handler + // (e.g. the Settings panel, which closes on bare Escape) doesn't ALSO fire — one + // Escape closes exactly one layer (the repo's "topmost layer claims Escape" + // convention). We listen on `document` (bubble), so stopping here keeps the key + // from reaching `window`; the app-wide capture guard in App.tsx owns the + // fullscreen preventDefault, so we don't repeat it. if (e.key === "Escape") { - e.preventDefault(); + e.stopPropagation(); onCancel(); } } From bd6d4831c0444a15f9dcdf1f56337d8cc87738c9 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 21:19:31 +0200 Subject: [PATCH 16/91] =?UTF-8?q?chore(skills):=20/land=20purge=20l'identi?= =?UTF-8?q?t=C3=A9=20Library=20isol=C3=A9e=20du=20/build-app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque /build-app crée une identité macOS isolée com.tosse.desktop.<slug> (App Support, Caches, Preferences, WebKit…) que /land ne nettoyait jamais → accumulation d'identités mortes dans ~/Library au fil des features. - /land : nouvelle étape 7b qui purge com.tosse.desktop.<slug> au moment de supprimer le worktree. Cible le slug EXACT (jamais un glob, qui attraperait la prod com.tosse.desktop) + garde-fou refusant prod/dev. Ligne optionnelle ajoutée au tableau récapitulatif (étape 9). - /build-app : note croisée renvoyant vers /land étape 7b. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .claude/skills/build-app/SKILL.md | 1 + .claude/skills/land/SKILL.md | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.claude/skills/build-app/SKILL.md b/.claude/skills/build-app/SKILL.md index b5d6b7d..9a3e66f 100644 --- a/.claude/skills/build-app/SKILL.md +++ b/.claude/skills/build-app/SKILL.md @@ -82,4 +82,5 @@ Indique à l'utilisateur : - Modifier `tauri.conf.json` (config de prod intouchée) - Générer des artefacts de mise à jour - Fusionner sur `dev` ou nettoyer le worktree (→ `/land`) +- **Purger l'identité Library `com.tosse.desktop.<slug>`** qu'il crée (données/caches/prefs isolés) : c'est `/land` (étape 7b) qui la supprime en même temps que le worktree, pour ne pas laisser d'identités mortes s'accumuler dans `~/Library`. Tant que la feature n'est pas landée, ces données restent (normal : tu peux relancer le build). - Signer / notariser le bundle diff --git a/.claude/skills/land/SKILL.md b/.claude/skills/land/SKILL.md index e6e06ee..5b92295 100644 --- a/.claude/skills/land/SKILL.md +++ b/.claude/skills/land/SKILL.md @@ -123,6 +123,32 @@ git branch -d feat/<slug> L'ordre compte : on retire d'abord le worktree (qui avait `feat/<slug>` checké out), ce qui libère la branche, puis on la supprime. `git branch -d` (minuscule) ne réussit que si la branche est bien fusionnée — c'est une sécurité voulue ; si ça refuse, c'est que le merge n'a pas pris, ne force pas, enquête. +## Étape 7b — Purger les données de test isolées du build (`/build-app`) + +Si la feature a été buildée via `/build-app`, ce build a créé une **identité macOS isolée** `com.tosse.desktop.<slug>` (données, caches, prefs, storage) — dérivée du slug du worktree. Une fois le worktree supprimé, ces données deviennent **orphelines** et polluent `~/Library` au fil des features (App Support, Caches, Preferences, WebKit…). Purge-les maintenant. + +⚠️ **Cible le slug EXACT, jamais un glob.** Un glob du genre `com.tosse.desktop.*` attraperait aussi la **prod** `com.tosse.desktop` et le plist prod `com.tosse.desktop.plist` — les détruire ferait perdre des préférences à l'app de prod d'Alexandre. On construit donc l'identifiant complet depuis le slug et on ne supprime que celui-là. + +**Garde-fou** : refuse de purger si l'identifiant dérivé est une identité protégée — la prod `com.tosse.desktop` ou l'identité fixe de `/build-dev` `com.tosse.desktop.dev`. Le slug d'un worktree `/start` ne vaut jamais l'un de ces deux, mais vérifie quand même. + +```bash +ID="com.tosse.desktop.<slug>" # <slug> = basename du worktree +if [ "$ID" = "com.tosse.desktop" ] || [ "$ID" = "com.tosse.desktop.dev" ]; then + echo "REFUS : $ID est une identité protégée (prod/dev) — pas de purge." +else + rm -rf \ + "$HOME/Library/Application Support/$ID" \ + "$HOME/Library/Caches/$ID" \ + "$HOME/Library/Preferences/$ID.plist" \ + "$HOME/Library/WebKit/$ID" \ + "$HOME/Library/HTTPStorages/$ID" \ + "$HOME/Library/Saved Application State/$ID.savedState" + echo "Données de test isolées purgées : $ID" +fi +``` + +Si `/build-app` n'a jamais tourné pour ce slug, ces chemins n'existent pas — le `rm -rf` est un no-op inoffensif. Lance-le quand même : c'est le **point unique** qui garantit que `~/Library` ne se remplit pas d'identités mortes. (Note ce résultat pour la ligne optionnelle du tableau final.) + ## Étape 8 — Clôturer la tâche Lance le skill `/done` (outil Skill). Il résume le travail, met à jour le contexte de la tâche et la passe en **Review**. Ce repo n'a pas de `/deploy`, donc `/done` ne déclenchera rien d'autre — c'est `/land` qui a joué le rôle de mise en intégration. @@ -146,7 +172,7 @@ Règles de ce tableau : - **Reflète la réalité, pas l'intention.** Chaque statut décrit ce qui s'est *effectivement* passé (push réellement effectué, worktree réellement supprimé), pas ce qui aurait dû arriver. Si une étape a échoué ou a été sautée, dis-le franchement (❌ + raison) plutôt que de cocher ✅. - **Si un conflit a eu lieu** (⚠️), ajoute **sous le tableau** un court récap : quels fichiers, et comment tu as tranché (surtout les conflits complexes) — cohérent avec ce que tu as déjà expliqué à l'étape 5. - **Ligne « modif de contexte »** : c'est une **recommandation**, pas un fait mécanique. Réponds `💡 Oui` seulement si le travail a révélé quelque chose qui mérite d'être écrit dans un contexte TOSSE — nouvelle décision structurante, changement de stack/pattern, info durable — en respectant la règle d'or (une info à UN SEUL niveau, pas de redondance avec un contexte parent). Sinon `✅ Non`. Si `/done` (étape 8) a déjà proposé ou appliqué une mise à jour de contexte via `tosse-manager`, reprends-la ici (contexte visé + ce qui change) plutôt que de la réinventer. En cas de doute, cite ta suggestion et laisse l'utilisateur trancher — n'édite pas un contexte de ta propre initiative dans cette ligne. -- **Tu peux ajouter d'autres lignes** utiles au-dessus ou en dessous des quatre obligatoires (ex : `typecheck + tests` verts, `cargo test --lib` lancé, build vérifié via `/build-dev`, tâche passée en **Review** par `/done`…). Elles enrichissent le rapport mais ne remplacent jamais les quatre lignes cœur. +- **Tu peux ajouter d'autres lignes** utiles au-dessus ou en dessous des quatre obligatoires (ex : `typecheck + tests` verts, `cargo test --lib` lancé, build vérifié via `/build-dev`, données de test isolées purgées (`com.tosse.desktop.<slug>`, étape 7b), tâche passée en **Review** par `/done`…). Elles enrichissent le rapport mais ne remplacent jamais les quatre lignes cœur. - Marqueurs visuels : `✅` ok / rien à signaler · `⚠️` a nécessité une intervention · `❌` échec/non fait · `➖` sans objet · `💡` suggestion à valider. But : l'état se lit en un clin d'œil. ## Ce que ce skill ne fait PAS From ab0eecd39ca85a39d23eb2132e60fc05bb36c699 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 21:55:51 +0200 Subject: [PATCH 17/91] =?UTF-8?q?feat(conversation):=20pin=20flottant=20"d?= =?UTF-8?q?ernier=20message=20envoy=C3=A9"=20en=20haut=20du=20fil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprend l'aperçu du Flight Deck (message court en clair, ou résumé Haiku) dans la vue Conversation : bandeau flottant épinglé en haut du fil, clic → scroll jusqu'au dernier message user. Réglage showLastMessagePreview (défaut ON). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../conversation/ConversationPane.tsx | 15 ++++- src/features/conversation/LastMessagePin.tsx | 62 +++++++++++++++++++ src/features/settings/SettingsPanel.tsx | 15 +++++ src/store/display.ts | 8 +++ src/ui/conductor-conversation.css | 38 +++++++++++- 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 src/features/conversation/LastMessagePin.tsx diff --git a/src/features/conversation/ConversationPane.tsx b/src/features/conversation/ConversationPane.tsx index 7f1d95b..e514bba 100644 --- a/src/features/conversation/ConversationPane.tsx +++ b/src/features/conversation/ConversationPane.tsx @@ -1,7 +1,8 @@ -import { type MouseEvent as ReactMouseEvent, type RefObject } from "react"; +import { useRef, type MouseEvent as ReactMouseEvent, type RefObject } from "react"; import { TodoBar } from "../todos/TodoBar"; import { ConductorComposer, type ComposerHandle } from "./ConductorComposer"; import { ConductorThread } from "./ConductorThread"; +import { LastMessagePin } from "./LastMessagePin"; import { FileMentionProvider } from "./FileMention"; import { ReviewBar } from "./ReviewBar"; import { AgentBar } from "./AgentBar"; @@ -43,8 +44,18 @@ export function ConversationPane({ // of jumping when the user flips it (via the chip or the global default). const cleanOutput = useEffectiveCleanOutput(session); const { scrollRef, onRender, scrollToBottom } = useStickToBottom(session, cleanOutput); + // The pane is the positioning context (position:relative in CSS) AND the scope for the + // pin's "scroll to my last message" lookup — see LastMessagePin. + const paneRef = useRef<HTMLDivElement>(null); return ( - <div className="wf-col cv-pane" style={{ flex: 1, minWidth: 0 }} onClick={onBackgroundClick}> + <div + ref={paneRef} + className="wf-col cv-pane" + style={{ flex: 1, minWidth: 0 }} + onClick={onBackgroundClick} + > + {/* Floating "last message you sent" pin, pinned over the top of the thread. */} + <LastMessagePin session={session} paneRef={paneRef} /> {/* Provide the conversation id + live cwd so file mentions in the thread resolve + open in this conversation's editor. */} <FileMentionProvider convId={session} cwd={cwd} inert={inertMentions}> diff --git a/src/features/conversation/LastMessagePin.tsx b/src/features/conversation/LastMessagePin.tsx new file mode 100644 index 0000000..c70e86b --- /dev/null +++ b/src/features/conversation/LastMessagePin.tsx @@ -0,0 +1,62 @@ +// A floating pin at the TOP of the conversation view showing the "last message you +// sent" — the SAME preview the Flight Deck shows (the message verbatim when short, else +// its ≤6-word Haiku summary). Clicking it scrolls the thread down to that message, so +// after scrolling up in a long conversation you can jump back to your last ask in one +// click. Gated by the `showLastMessagePreview` display pref (on by default). +// +// Text source mirrors the Flight Deck's LastMessagePeek: the live Haiku summary +// (`useLastMessageSummary`, only present after a send THIS run) when available, else an +// instant truncation of the last user message read from the store +// (`useUserMessageHistory`) — the latter persists across reload, so the pin still shows +// on a freshly-reopened conversation. + +import { type RefObject } from "react"; +import { Ico } from "../../ui/kit"; +import { useDisplay } from "../../store/display"; +import { useLastMessageSummary, summaryPreview } from "../../store/lastMessageSummary"; +import { useUserMessageHistory } from "../../store/conversationStore"; + +export function LastMessagePin({ + session, + paneRef, +}: { + session: string; + /** The `.cv-pane` element — the query for the target message is scoped to it, since + * up to 3 ConversationPanes can be mounted at once (full view + Flight Deck reply + * modal + Git workspace) and a global lookup would hit the wrong one. */ + paneRef: RefObject<HTMLDivElement | null>; +}) { + const enabled = useDisplay((s) => s.showLastMessagePreview); + const summary = useLastMessageSummary(session); + const history = useUserMessageHistory(session); + const last = history.length ? history[history.length - 1] : undefined; + // Prefer the (nicer) live summary; fall back to a truncation of the persisted last + // message so the pin survives a reload where the live summary is gone. + const text = summary ?? (last ? summaryPreview(last) : undefined); + + if (!enabled || !text) return null; + + const scrollToLastUser = () => { + const thread = paneRef.current?.querySelector(".cv-thread"); + const nodes = thread?.querySelectorAll<HTMLElement>(".cv-msg.cv-user"); + const target = nodes && nodes.length ? nodes[nodes.length - 1] : null; + // block:"center" clears the pin (which floats over the thread's top edge) rather + // than tucking the message behind it. + target?.scrollIntoView({ block: "center", behavior: "smooth" }); + }; + + return ( + <button + type="button" + className="cv-lastpin" + title="Aller au dernier message envoyé" + onClick={(e) => { + e.stopPropagation(); // don't trigger the pane's background click + scrollToLastUser(); + }} + > + <Ico name="reply" className="sm" /> + <span className="cv-lastpin-txt">{text}</span> + </button> + ); +} diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index ee3b309..454224d 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -194,6 +194,7 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => function DisplayPrefs() { const cleanOutput = useDisplay((s) => s.cleanOutput); const showTaskNotifications = useDisplay((s) => s.showTaskNotifications); + const showLastMessagePreview = useDisplay((s) => s.showLastMessagePreview); const set = useDisplay((s) => s.set); return ( <SettingsGroup title="Affichage" icon="list"> @@ -225,6 +226,20 @@ function DisplayPrefs() { onChange={(v) => set({ showTaskNotifications: v })} label="Afficher les notifications de tâche de fond" /> + <ToggleRow + title="Aperçu du dernier message envoyé" + hint={ + <> + Épingle en haut de la conversation un aperçu <strong>flottant</strong> du dernier + message que tu as envoyé (le message en clair s'il est court, sinon un court résumé) — + le même que sur le Flight Deck. Un clic dessus <strong>fait défiler</strong> jusqu'au + message. <strong>Activé par défaut.</strong> + </> + } + checked={showLastMessagePreview} + onChange={(v) => set({ showLastMessagePreview: v })} + label="Aperçu du dernier message envoyé" + /> </SettingsGroup> ); } diff --git a/src/store/display.ts b/src/store/display.ts index 2ea4400..2865f55 100644 --- a/src/store/display.ts +++ b/src/store/display.ts @@ -58,6 +58,12 @@ export interface DisplayPrefs { * clean render (SpecialMessageCard) is kept, just gated: flip this on to see them * again. Read by {@link SpecialMessageCard}. */ showTaskNotifications: boolean; + + /** Show the floating "last message you sent" pin at the TOP of the conversation view + * — the same preview shown on the Flight Deck (the message verbatim when short, else + * its ≤6-word Haiku summary). Clicking it scrolls the thread to that message. On by + * default. Read by {@link LastMessagePin}. */ + showLastMessagePreview: boolean; } // Off by default: the transcript shows everything inline as before. The user opts in @@ -71,6 +77,7 @@ const DEFAULTS: DisplayPrefs = { fleetBannerConversation: true, alertOnBackgroundWait: true, showTaskNotifications: false, + showLastMessagePreview: true, }; function load(): DisplayPrefs { @@ -109,6 +116,7 @@ export const useDisplay = create<DisplayState>((set) => ({ fleetBannerConversation: patch.fleetBannerConversation ?? s.fleetBannerConversation, alertOnBackgroundWait: patch.alertOnBackgroundWait ?? s.alertOnBackgroundWait, showTaskNotifications: patch.showTaskNotifications ?? s.showTaskNotifications, + showLastMessagePreview: patch.showLastMessagePreview ?? s.showLastMessagePreview, }; save(next); return next; diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index 94b902f..ef3505b 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -109,7 +109,43 @@ /* Single source of truth for the conversation's content width. The thread, the composer (input + footer controls) and the floating bars (todo / review) all centre on this same max-width so they share one visual column, à la Claude. */ -.cv-pane { --cv-max:840px; } +/* position:relative → the pane is the containing block for the floating .cv-lastpin. */ +.cv-pane { --cv-max:840px; position:relative; } + +/* ---- pin flottant "dernier message envoyé" : épinglé en haut, par-dessus le fil ---- + Reprend l'aperçu du Flight Deck (message court en clair / résumé Haiku). Clic → scroll + jusqu'au message. Fond translucide + flou pour flotter lisiblement au-dessus du fil. */ +.cv-lastpin { + position:absolute; + top:10px; + left:50%; + transform:translateX(-50%); + z-index:6; + max-width:min(calc(100% - 64px), var(--cv-max,840px)); + display:inline-flex; + align-items:center; + gap:7px; + padding:6px 13px; + border:1px solid var(--wf-line); + border-radius:999px; + background:color-mix(in srgb, var(--wf-panel) 86%, transparent); + -webkit-backdrop-filter:blur(9px); + backdrop-filter:blur(9px); + box-shadow:0 10px 26px -16px #000, 0 2px 8px -6px #0009, inset 0 1px 0 #ffffff08; + color:var(--wf-tx); + font:inherit; + font-size:12px; + line-height:1.3; + cursor:pointer; + transition:border-color .12s ease, background-color .12s ease, box-shadow .12s ease; +} +.cv-lastpin:hover { + border-color:var(--wf-line-2); + background:color-mix(in srgb, var(--wf-panel-2) 92%, transparent); + box-shadow:0 12px 30px -16px #000, 0 3px 10px -6px #000a, inset 0 1px 0 #ffffff0d; +} +.cv-lastpin .wf-ico { width:13px; height:13px; flex:0 0 auto; color:var(--wf-accent); } +.cv-lastpin-txt { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--wf-tx); } .cv-thread { flex:1; min-width:0; min-height:0; overflow-y:auto; overscroll-behavior-y:contain; padding:22px 26px 0; } .cv-thread-inner { display:flex; flex-direction:column; gap:20px; margin:0 auto; max-width:var(--cv-max,840px); padding-bottom:24px; } From 7cd1d46b7634248bc5eaaa8087120d7932634371 Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 22:00:14 +0200 Subject: [PATCH 18/91] =?UTF-8?q?fix(conversation):=20pr=C3=A9serve=20les?= =?UTF-8?q?=20retours=20=C3=A0=20la=20ligne=20des=20messages=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserText rend le texte dans un span `.cv-user-text` (white-space: pre-wrap) au lieu d'un fragment nu — sinon HTML collapse les `\n` et les sauts de ligne du message envoyé disparaissent à l'affichage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/features/conversation/userText.tsx | 5 ++++- src/ui/conductor-conversation.css | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/conversation/userText.tsx b/src/features/conversation/userText.tsx index cb9c130..6795cf1 100644 --- a/src/features/conversation/userText.tsx +++ b/src/features/conversation/userText.tsx @@ -32,7 +32,10 @@ export function parseSlashCommand(text: string): { command: string; args: string * is dropped), everything else renders as-is. */ export function UserText({ text }: { text: string }) { const cmd = parseSlashCommand(text); - if (!cmd) return <>{text}</>; + // A plain prompt keeps its line breaks: the raw text carries `\n`, but HTML collapses + // whitespace by default, so we render it in a `white-space: pre-wrap` span (the fix for + // "my newlines vanish once I hit send"). A slash-command shows as a chip instead. + if (!cmd) return <span className="cv-user-text">{text}</span>; return ( <span className="cv-cmd"> <Ico name="wand" className="sm" /> diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index 94b902f..a1eacf8 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -133,6 +133,10 @@ (flex:1) fills the rest to its left. */ .cv-user { flex-direction:row-reverse; } .cv-user .cv-bubble { background:var(--wf-panel-2); border:1px solid var(--wf-line); border-radius:10px; padding:12px 14px; flex:1; min-width:0; overflow-wrap:anywhere; } +/* A plain user prompt keeps its line breaks + multiple spaces (HTML would otherwise collapse + them). Shared by the live bubble, the inline "while you were working" marker, and the disk + transcript, since all three funnel their text through UserText. */ +.cv-user-text { white-space:pre-wrap; overflow-wrap:anywhere; } /* A slash-command user message rendered as a clean chip (the raw <command-*> wrapper is dropped). */ .cv-cmd { display:inline-flex; align-items:center; gap:6px; font-family:var(--wf-mono); font-size:12.5px; } .cv-cmd .wf-ico { color:var(--wf-accent); flex:none; } From 9f1011485c0dd8c7c72c9eeccff22253ee6b674b Mon Sep 17 00:00:00 2001 From: Alexandre Josien <alexandre.josien@gmail.com> Date: Fri, 3 Jul 2026 22:07:52 +0200 Subject: [PATCH 19/91] =?UTF-8?q?feat(brand):=20renomme=20l'app=20en=20?= =?UTF-8?q?=C2=AB=20Flight=20Deck=20=C2=BB=20+=20nouveau=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nom affiché « Tosse Code » → « Flight Deck » : productName + titre fenêtre (tauri.conf.json), index.html, description Cargo.toml, 3 messages Info.plist, menu app macOS (lib.rs), UI (sidebar wordmark+tooltip, hero Réglages, dialogue MAJ, hint Dock notifs), release.yml (releaseName + en-tête) + note d'installation Gatekeeper. Nouveau logo A1 (avion cyan + souffle réacteur corail, centré) : public/tosse.svg, src/ui/TosseMark.tsx, src-tauri/app-icon.svg + 16 icônes PNG/icns/ico régénérées (pnpm tauri icon, extras iOS/Android/64px supprimés). Identité technique CONSERVÉE : identifiant com.tosse.desktop (zéro perte données/TCC), repo GitHub, certificat, noms internes crate/package tosse-code, et nom « Flight Deck » de la vue de gestion d'agents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/release-install-note.md | 2 +- .github/workflows/release.yml | 4 +-- index.html | 2 +- public/tosse.svg | 20 ++++++++---- src-tauri/Cargo.toml | 2 +- src-tauri/Info.plist | 6 ++-- src-tauri/app-icon.svg | 22 ++++++++----- src-tauri/icons/128x128.png | Bin 1877 -> 4227 bytes src-tauri/icons/128x128@2x.png | Bin 3447 -> 9350 bytes src-tauri/icons/32x32.png | Bin 688 -> 904 bytes src-tauri/icons/Square107x107Logo.png | Bin 1640 -> 3482 bytes src-tauri/icons/Square142x142Logo.png | Bin 2118 -> 4631 bytes src-tauri/icons/Square150x150Logo.png | Bin 2086 -> 4928 bytes src-tauri/icons/Square284x284Logo.png | Bin 3809 -> 10366 bytes src-tauri/icons/Square30x30Logo.png | Bin 534 -> 804 bytes src-tauri/icons/Square310x310Logo.png | Bin 4049 -> 11564 bytes src-tauri/icons/Square44x44Logo.png | Bin 783 -> 1305 bytes src-tauri/icons/Square71x71Logo.png | Bin 1042 -> 2068 bytes src-tauri/icons/Square89x89Logo.png | Bin 1418 -> 2780 bytes src-tauri/icons/StoreLogo.png | Bin 923 -> 1451 bytes src-tauri/icons/icon.icns | Bin 40253 -> 114194 bytes src-tauri/icons/icon.ico | Bin 8116 -> 16237 bytes src-tauri/icons/icon.png | Bin 6492 -> 20490 bytes src-tauri/src/lib.rs | 2 +- src-tauri/tauri.conf.json | 4 +-- .../conversation/ConductorSidebar.tsx | 4 +-- .../settings/NotificationsSection.tsx | 2 +- src/features/settings/SettingsPanel.tsx | 2 +- src/features/settings/UpdateSection.tsx | 2 +- src/ui/TosseMark.tsx | 30 +++++++++++------- src/ui/conductor-conversation.css | 4 +-- 31 files changed, 64 insertions(+), 44 deletions(-) diff --git a/.github/release-install-note.md b/.github/release-install-note.md index d3aaf1f..3dfd9f6 100644 --- a/.github/release-install-note.md +++ b/.github/release-install-note.md @@ -1,5 +1,5 @@ ⚠️ App signée (certificat auto-signé) mais **non notarisée** : au premier lancement, -faire **clic droit → Ouvrir** (ou `xattr -cr /Applications/Tosse\ Code.app`) pour +faire **clic droit → Ouvrir** (ou `xattr -cr /Applications/Flight\ Deck.app`) pour passer Gatekeeper. ℹ️ Sur cette mise à jour, macOS peut redemander **une dernière fois** l'accès à vos diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29fc934..b4b918d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# Release de Tosse Code. +# Release de Flight Deck. # # Déclenchement : MANUEL uniquement (workflow_dispatch). Pas de trigger sur push # pour l'instant — on verra plus tard. La release est construite depuis l'état @@ -211,7 +211,7 @@ jobs: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} with: tagName: v${{ steps.version.outputs.version }} - releaseName: "Tosse Code v${{ steps.version.outputs.version }}" + releaseName: "Flight Deck v${{ steps.version.outputs.version }}" # Composé à l'étape « Composer les notes de release » : nouveautés (CHANGELOG, # affichées in-app) + marqueur `<!-- gh-only -->` + note d'installation (GitHub only). releaseBody: ${{ steps.notes.outputs.body }} diff --git a/index.html b/index.html index 3dcc1b0..13d43f3 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/tosse.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Tosse Code + Flight Deck
diff --git a/public/tosse.svg b/public/tosse.svg index 3c7c2d4..de3cd80 100644 --- a/public/tosse.svg +++ b/public/tosse.svg @@ -1,13 +1,19 @@ - + - - + + + - + + + + - - - + + + + + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 53c6ad1..b6afd86 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tosse-code" version = "0.24.0" -description = "Tosse Code — desktop app to pilot Claude Code" +description = "Flight Deck — desktop app to pilot Claude Code" authors = ["Tosse"] edition = "2021" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index eb8233a..86ad595 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -13,10 +13,10 @@ NSDesktopFolderUsageDescription - Tosse Code a besoin d'accéder à votre Bureau pour ouvrir et éditer les fichiers des projets que vous y placez. + Flight Deck a besoin d'accéder à votre Bureau pour ouvrir et éditer les fichiers des projets que vous y placez. NSDocumentsFolderUsageDescription - Tosse Code a besoin d'accéder à vos Documents pour ouvrir et éditer les fichiers des projets que vous y placez. + Flight Deck a besoin d'accéder à vos Documents pour ouvrir et éditer les fichiers des projets que vous y placez. NSDownloadsFolderUsageDescription - Tosse Code a besoin d'accéder à vos Téléchargements pour ouvrir et éditer les fichiers des projets que vous y placez. + Flight Deck a besoin d'accéder à vos Téléchargements pour ouvrir et éditer les fichiers des projets que vous y placez. diff --git a/src-tauri/app-icon.svg b/src-tauri/app-icon.svg index f76f89a..29215be 100644 --- a/src-tauri/app-icon.svg +++ b/src-tauri/app-icon.svg @@ -1,20 +1,26 @@ - + - - + + + - + + + + - - - - + + + + + + diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 3b924fec2732d77c2ff952e1d4ce1a0c86305c53..72f8a4baf7949d1789c1b7aab88e5867ba3e1924 100644 GIT binary patch literal 4227 zcmV-}5Pa{6P)-=LA5zf@9(+SKER44@bXrV35S83Yh-gnRY-kanmP0}t*%bT(AH-u32RkWPw&3w+hvSn2IZXg1#P@C{1R6M~DS#sYMg{BL znM@I;%z;1oSALQ%JO{yI+>-q7D}$U35qVCj^N)6?mbP={q7sN zu`hsE0I6Bw#S@|Y=G&p@is@j*=fQC#`0w0~jYA$+LC9Jwm@^<*E1NkRO3}lMF25SR zz9uwsTJYU_n!$hOq%P}wcnV0f6<}yD#;{&G^JaEX#EA+!QNIw-d=DSGc5L8#19LjG zkDCN#DB`HMTQ8mz1$G#Gt=9*^_b~52#R-sWR*v3XhDXU2SA!Y7o}x*#2~q_=R>;@B z?s{PZUKoF2~a@xYJ#9er~@CqNvt&zi%+x(z+uQeCG3vOoB-#2rKd z9Ubz6zoU2V=L8520ETiKRqR(@gP_mB&|Ve=RwVebLVhx0PYb@@%757R0j#6zz>c8X zuAPe^{+fiIN;Dxm5%}83K)ZTkaVeDGIkfLb)UZHve|qYEo%#T|*a<8{53fKEw_iI4 z3XlMVggrV4Oa!n=fFCXPXh?5e^BC*)1;G#he)t^J`!g|yS?mPnMrfyCmm~1@S#z0+ z4|-}mV8Xx-AogN_-?42ybTw~G!sL2CYv&2ELXa*|Zgf-a3%*TD0KTJ+%ddP+K_uIgFP3bFL3?c-3+w!6eq6 zjR$_Be4nb=qdA*;efR&~>$jpO41RyHr%fx=`vV!x{i*i>a%xTB$4l>kUd+4EV38!m z-az1!e&7=nd28YDU9zGYfIeo*F3(KfgexoX(Nq<@8`ni5XsVj;qIXPjsQVB_ttB0 ze8X=OclJo65E}@5H!AxhOKwXD{^(^-K_Qzv7#MtdPIq%7lFH5R7M=-f``dF+jDoQ0 zmIXr+d$crb{mV)u!0v?Squ`g*7H>cBV>gs!EE(OeJkM_zpGiBq`e8O_PbYek>+D4~ zi#m5U!r5)lM3i>1tZL!U6!4>)MRCr_A_|6nqkDf|Ob7qsi*4|<-44Qqmn7ZU>p6M| zS~vawg8BbipHS@4 z((bb#HY5!`Yw<3BDxug5clt<#@>je+H!Nof@bGM0uvuVM0KnrmOFLY(dqbqyBWnMC z<4R~*erM7va9N9Y_0||-j|?awtWR8xyDWf1VMTJym7p|;AF)cewKPr)x3Hz|SGi#7F3LZzQeRsMR7 zO{(2yG4(z`_K!QR90R2mlLq{71SqH)1%e4hp%g$8NMI?j!0z8akktE*TD(F7emt>9 z;?5otFZKRL-L+9(r+}LBBDiPzMVgQ=hky~xX0X`o^lphrfbdaNQVegbzCYpnUa))> zF63M=D6tnkdzfp=KPL(B%h{KPygoVtSVvSVfS~6CJ1{LsGJ70XKL$1~SrGF*M{B^3 zp2pRRJrZXJ5D=y7my5YSrwA~urUIs3VCyIP=%cBq7(}a;<^Iq?BY-R^=>VU9qv2|J zc>WDhUA_g_-(N6ju?JC$P;%M(b7p(^;|<}mPoV1&RNjiB00pS}DadqJ459U)+78DL zQoz@0dt4G=$#-vsLtmbSzrT;`_i+ZX8ofXEG;Tbx*M9*D^L``%G00Y_yxboClW7;i z=#m2V#|aRw_u0jlm9hO$lmL{L0z+I@KoV5+*&`CfWP+z3`~iHl?E|nrv^u5P!|2sV zo~GRPe#57LnzBOp-d8S&nfCz|zp1b|6z*Bx4~BjA7<{d!1GO^Pu<#}z=RGNjJuUdM zH@9gaO#`nala=vR@T&&8r5{9cR!39+n=cHdtmk4yWII5sqAUN8(K-5ND$a=6>%hjgSv!*zMu> zT~()oPZ*d&P>dmf=7K_2<`Gfxb_zZ*DS%TF2on0g#|3`3i`_w)>Hq=TgMui8G{hf- zgK+nME8*OA%$6-MmGY`Tk+EJ$!b3CDdX5_saw@p|_2zCH-}&V$r*o_RVwXkS=K12imzpVM@*%KIiP{_-M_$6S)+zC!B#Ztym5 zg?ur?O$QfSM7Zd$Acw^t)_qEO7f{5Wtct!quNQo0+rZb}riA(!{eoCR zvRd@RreoXKG;Tcbqc?oGwy#xlKQZ8u2h+jdnlKW~2y%~K@lq=Iitx2bq{82>iJRC0T;GJs%}K*G(UVB<(o zdd2X0yBD^7&;ol8w}IQ&7drR3O)H`5p;ZYtmAH4TgM2eXEegGD!UzVw=%;bl%yPS^ z5p8KtY{Z zSB~CK=qHjXIb8)k7y;NLQ}#IbZ&E#+1pn;dJORLmr@P?QFWO*lOFMXa{e!-i&NYu9 z2_}Jg?8FfG67~MqcS1gzAqa43Wf2r$W2ZMDt6+z_dK7R<3Xy>?sXsS?*+d|?7qZps zgO@%&4f`COaN6B7`&!6X)13e%=*}10i&)tw3QFS#t34*? z7}$P|4hTg9Z2e%B@b<|rcW2Mz>^|C&(Shk}`4qa=Jj!+(ksP@*W-8mmb&UY! zRx{WOO$fdm>PoTnA5grUl8q9ewc8H|+T8G;(=O=lld^VwJ@4#>d^cSQP*ZAUd0&jk z_39c9^tuCQdf?NJZfNuN0=GOPfW6RyQ_ zJei>s;LOH)fPKcC3+D&A)`2V#!0h0jX4ru4$h>T%C7gYP69tEj54fS$1G=2fj;wur zJWT_)^I1@>ieV~Ehi2i$N3Xv!i1N;m-wXC>JqfYa%4V$NyggKn7o-$*gZPVX=z z1V>IGMB2}X-v|UK80&PlIkHLsYBpQdKAbDRkwc&>{LOB6=RDA3cd#H>7SZy9gP>pO zCyJJ(LwOI=Q2w*e=XF}GMYvH98aN!;H(WZm!s%@PM|S5AW2S-%Oa;JGL3Ry8f7d)i z+#o^_Z1XT29pso~E%YB0McX2MRuIIk3hM2$>~ioHaW?Y4tv=PVFJrfep%+9i44?8y zkU2Y;PVmzj0m6~MZpS75v@69mL7H(NAQ6_LK)j1%np|%m zf&UW%f53R_h=;BZXzfam25ckqib1)nsY_X70Zj;eQ@ZoKVF(Z_Mc@rT>W5<|5xaO! zTpWD>?|S3NWQaF30k{F*9?p#ufEy6) delta 1863 zcmV-N2e|lyA=M6$BYy`RNklQl*EP>L%aYo0!mAPPA`;cyEDh% z*#=Auz0B-f&e{1saV<@Tk;MRp;r4R$2`H5k zirz?K0DqbL*Xz!8Ou2fhZ`R*pkBpPtBF*qF8eVxt(S#w zEo`8!j`g8k|Ao$u6@UN7o$1it`}Xuh`e$k`#Cm}Ds;n{s$miPVqc?h}XZuqv{ge*h zyif0aH%v43q8e7iXAL^=aiqGN_A>c0xoG7f?|)I=YZ!AFVJSHH9B}WplK;EV42^pw zp~MnTWn4g4J|`DCBtii4Ua>VS0;>XOBmjZZq8!y9tv_>xY}clZi4Z{T>K(o70AaZ6FNgkYyuMx8(bD99HFAT# z7=OA(AuSWv*9FYXFVZLHes7GR(ZiLu&)@9vbk4iq4%6_hNyGrHw1^%EL;$XqIk(p| zbLn{9L?d7UkY$rJ)HLy}zp znoR+wf%*kU!t(>|Cv{X{cMvQ9(m)7cnST&KKrcuG0i=OQ1Cv<^Bvb;ET?Y`T10V(f zlG+aT5d#Q9JGiw<07wH-3I-Hj6pAcBt3{!x1E3v@b}(W9q=68CR!#s=d3!k410;KU zINHH*0oug?nS6n^9XRCaoU!k}A^Y}Cw1ag|110&deto|2>IGZhIz*QaAE4PQmw%N= z15=4g13i7|o>-MgV`B$7e5{ut&)I(P2r7ZP?+nUU_L1vz9`Y~07RnrIy**sFMWK>> z*C&fcdk5OVkw*X`*mkhIp4-%8EYJ?txgE^ABbpQd(m<`-!OdF$Y!6qu1h85V=!m7E zQfnf@MWG@g-9X)90JMW6TNUgI#D4&ZP6GuI1d!w)Fu@i8+ru9PT&_@Skt()_N7xQ_ z_yC9C0+Jg8APCe4V-FTQTL5{^N~q6H`xZcb{BLFECe=i3Rhu#PV4lV$p=)df%VXs^ zD_wK${Ev?VL9gTCrJ<1S)jQH&PsnE5tNc>c%?gZ8%+V`dd1`N4`Ig7$7Juo3pKj3o zhxHYYTe$WsWwvai%yTb#qR9T`0^R%geG&`vAq2a|Iop0v3$=7qEOs8}w>qY)JJ?mq z(Z*#&FuUl`)k)i%4`mUgw>-0^V;wgA_3>LF-ktHGW!*WB7+?i>C3tmzek){BJqf^y zAO>)2)bfAeRoCH+L?D2SNq>NRKOZmMz|sot8CbMk)Y_LSa*ln=HMcAXfU%Oi$nIqiCB!kCfNeFVhc@5Px6lmrAt_G;@t~ zGbh{jtd-5K&yj}Bp;kaDMMo`b;)iJ05kJutl-LyzTtT#8(?8i4(|Umao1#J=o1;;1 z5}D~AO{EI`@$4j6HY+F>U3bbvi8uF5J2jl<35`>4*AjuEZ0{mwQ zibcmES(QRe5U|frr+=y6Vgz~7bkLvDW-Kh&Cv&;n6q~Dk{G%MWYtH2JI65<94am8T ze>b5wYfxrua1z#_si%H`A9_=rZx{9_gbE8`fCI#L!0Fm*`%L_&O#Fy?UoYK!d?0gI zIjUSnM#j(}+?ZNgV?l#V{9w?2UKs&A$wk!bR8C?gk*jzXe?ZyBNG3yOm?mCn0RV)p zVE_aGKmY&)06+i$1OPw)00aO)000C4Kmb74_!p~3`EZBCi6Q_1002ovPDHLkV1j3? BQC|Q6 diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 827c3797df15c1f5a158aeea384973c56d260a6a..f2d40b59e53b25f8256f3f77f9a9e5fba75450d0 100644 GIT binary patch literal 9350 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 Hu#fs5^@~vA literal 3447 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 IM6N<$f&puyW&i*H delta 665 zcmV;K0%rY)2e1W@BYy&SNkl8<0YrW1#vQ#-6a;;V#2fzk5&y%?%%ufH_L!DK8XXY}k~HyM zRe#o9o5zAUSt%{!Ubk!<5CTGY&ROI&aeNdmPUIpk%^uIdbUtg#o5~*S{V^hIzV51^ z>HFbeg`;|7{ztO-YgY$=nH;6vu6OOVeRXZFT)N5rA;~-8eohe~QdMxPF29d0T$-c@v&} z+!#C)I$)9ST$8ul&{%S>opO7CR}o_-0Wdo9PykR80)JjTsd<1uwUSY0cH|`A#+-sf z=76Jwmjlel0S7=vl;G^Gdo z9|tgd3R;GjHTPL>Zbb3`|1tCcxFIlT)O`R94sL`1vup%AoHCEx+;IS~NIm3*!I zWf$r@ntv^?E4pn+eOHFo`l=U#jkPWSghm_(6kZZ)Z39+zeh21EV!YY-(4#iQ!2dZ^ z5eb#_Do?J`84hTR2kQr&0obB57;g$QQT^7aWceSvvl{o7j$YtF~2|q zfq??KZm!QgH)z*Z%qyCvyx_W+9Ni87cl!&XI1=~~^>1I-00000NkvXXu0mjf&sj3> diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 5ecc1a831ff66798ea96d36be3d4f0bd5d2ed1e4..5e1527f99ec540712e67ecf59e7a8b6c1ec47984 100644 GIT binary patch delta 3481 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 N002ovPDHLkV1hBP)P(>5 delta 2106 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 delta 2074 zcmV+#2<7*{CZ-UOBYy|&NklJ~^x#!b!&OP_cOp+id03|U55P!ge02TzWAP8VV01ET1mvWkR3c z;pc48*R?{|EguI|3a0aQo0WNzMf-j8f_@f1`2h1Uld^E-mkZeO35AUixNsH~b@eOs zLUi=|5fXjzSdLR322?tDEi$DoW8<;5Z|}R!2Zh{953glUOkD1c++%IqR#>TA@q}BB)G11$%@2d)oN&i(N)dbHrZUF=Wt7m z3oB@u#5)qK(MZ5zrUt;3wguC@k$Eisa1$obR=G%Kg{HYHhq2dN&Dhwp$EsGb*sQy% zRqyplWiGLQtZN-@?(7Kd^Lo~&>)V4gJzuGBVSnlJ%~>jymk5{G`=CsF8yz|DEai9h z!iBI3zulnsPo9rGV9m}71J~hwyJ+CaUc&6NAvqRuOg{YX=P=d{qwmKGN-sUVr9m*Z zJ6nBB^bUo=yOB)V2<3BBQg~w`^v$sl0txrW0$Gqc9t&Zx^}q^c0UBIIHugdYo$o;k zvVRZ?75&SES~7WukpNZz`m3}+n=`lz(%rgVD#!j96V1DmV7IR?Ttl_}o~=G6A&kA& zYlWYl`JFbTlk{qCJ6sj(i{eCmOb`lv&`0CH)VJrhb_e=7eUD^l`1w9h`;L5nncG@Q zfgayaledv$DLb$p-LQm8CBw%Utg3c{;eRfFd0!#WZ38F+3JRT5EQYMv=aUpP4zPgQv<{hMngb0ONRNh?>39wLk2UrM&0IQV?q|Mc_fTc1Wq+!nry{{Fz+JB8l zGk2RoC`2qoj)mkM9i-?WC3KKNsAC}%BFDn45FMld3xD3IV-Xtt5DAfEp@S4)L8xN^ zEQCV6uPa0csm>jw)hd#A^t>Y!;@%Aa3(0#R+W5i&>OF9fl9|pm?N_-zNt0iGMsvSj zK!FrjLa)@;R}Rw?Z@x!fAEx{0i+=~{{96afKKlb{d=FBM!(l+T>onguLJoVyX0{#t zJFr%W4$?d7>fgs;G=H2OJNJY#_UIs0D93VIqyP)BLQEUwuXYGv5jseH?jWt_NGh!n zpo3J24pJAP5RYpkJah`P!Z_sZt6650|L<2Q)O=Qm4$`$0NM#=8U-FKJjeoaL<^AD5 zgB%OxhCo8C<6;pS$vdGy3a}J#StvKO^gT!^unyA3TBJ&43ncH#bdbVhq4JK(JHS$; zxdF4n)+~@t%~w31(`xozy78wk9?O|8#eVG3LAnBtMLPHQ+?t;%FZYrAK1|gcleBRD zOw%2tbKiUw`>`~r$)w`Exht>D|*4v{+l62U+;> zTWZ^#Bcpe#rw(Yj|LspcAa}76_*ghj;#j#nXN5YS%w{*`iE5{G^uJ?WqEicb~(eS7=rv85ZS+xLce{C|>%SxASrp`(?`LLnC24T(hOh$J0mQQuohTT)!wW^CKCIpG_I zX_Qaehovo}?Uuify1IH~!E^v7QWC21nVIQ)EL)R>$P5zASV-iR3xP92bh7QHDV80i zjgXi`60aOs$-G}7(AQbv-71&=nM$SBb0Y5!!elBa@!IU{t$&jgm)7J5X>oC(m`(#O z6`(4Wrbj7GJ@0@EP=z)tr2AIovw6p%F(mZyNz@Yw;+)DZDt-AEpFn|6#N@YFtd3qM zq%DGmDin`(kM|m6?=`~S>v7mP?20;Z@zWloxL;T3ZN^x>)X~vC%5YWqWcwMgc9=a_ zpyb-0VbD+*UVkbwk9Dsynd}gs_z)-fsMh=3PsJ}|rII}w*)hanAUH&twOYM9BF~`l zGp^jdz+-I}J}mdB7uY)$*gIK~A5mO~ieby~!N^SP;O1Ab07*qoM6N<$ Ef++U$mjD0& diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 4e7f8c253d1f7b7fec78c3551f66441289bfcc37..7fe70763ab64f9a5166f05eda5813ec9a1ba0c1c 100644 GIT binary patch literal 10366 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⁡ECJfd<-WG{XftDo%h`g{HKwz4c10w<^E`X-}V> z)b^<6Qq|Pe8cNEMU2}w@7VamvA^XKdT)jDP_U^qf)we)6+Jej~D-=pI^ZWrpeTgln zWEAH&9EWhA7JsIOR&d4T1XHm@>SX;Y!8`OUX1})Rch}1e`RhcxXF=Eb4dJEpjgPR(CS>Rva3LS+%6ib)n7zK9dEP zWrQ4%(0_K=ih~txaOVh6lx3579d^C9o7xs5fvrV6n#u%#^k#ghJ?Hzv0}E zQZSZ1`rAobQM*ZS_&RQ}k?#=!GGkbaWa({7v}LyGf}|ib>I@-pg%G- z7u=Hl$>VV|3H5l=A57aO$!@qGinSb42`zUla9w+lw8i*}RY00000 MNe4wvM6N<$f(WC3i2wiq delta 509 zcmV|rtcUT84@2nxUm4I#Xw6P{{8#EpB6#O#KhR zXys$nR4o~w&%kS70ax>Q)ofBM{zs&b5sB*`IC1@B_z83f3pCz|F^g(G{`vPG0~3nH z|EZs(}=SY-}6{ zfh|KpphGn%Y+VAQjv@2Qm-ib8Hg89}Fth>yt2~(99G8wW00000NkvXXu0mjflZ*4t diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 364cef539ab50bcd7c81d536a51538033a7dc05e..b037e9fdf86f7aeb3cc506fe70c210b2e1695a01 100644 GIT binary patch literal 11564 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 X7DY||&Y~l$&SGk8d9~(>Q{4Xm%!1tn diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 17b5740b4170d69adfb4859e2fe7aa12960605f0..5340d3c3c9e8eab8a89ea8b61d260ad884d2b02d 100644 GIT binary patch delta 1286 zcmV+h1^N1q2AK+wBYy1tUsZ>&+}esk@4C-3KUGZ$qlsczIQ(Fd(S=R-cu9)XMfGK1-u2E1+de3-(EmD z3^1qqag7<=^la+v3%C1vf2P4KGblf;D5%ecdW@k55RRtJ^1`(c3$6JLybj zOnJub9_%p;tSYN0{Ch>oHEcp@6Fxn zY$s5$q+A13pU)L79w0dI6ZNaBNzHt9xt^w4B0vqawto?fKJx_;60iY+%)9?#4S43l zM`>nM$qdZ1;LR(oKp{ao>JGtPvrkh`gGBsq92kg9jbf(jQ|x&D6%9U=L0!FxHf8I- zR)Q>RYSAE7-{cGan1KB+uxfbfdPgDw$&tIQ7($Y}B7O<)Vg zd2-4V|9{8Vga5ZaOnlOg`N=VqpE?(ZrE99Pp@F^?@67}~g%b-eUH1=N$F*b4OJEn( z1!DeL-1@i;!*9NTxrtGfzjscH3<>qU=g(xcp(mWMngtg4^AC7CP9z}{Fuu#*V(7@z znC?2OjjJGK&39KNl@F1+;oq-jWmC{e9EKE5^nVXarcn4pmj)9hRfpd>0{7c5BQkzd zyT0r7c7m;5;?qXdjd#9wi%xB=wUsEdTkt{nGTy)F!Dk(3G*?Kp(l<`Q-uUd2MHpAN zFY8$q^un5vx3^nNXs+1}9*e=Uvjp!}TkyoaWy$M1TTf`P$^tB3P*6nJp0V@@bVtar)?Us3?{o9cqH)dMT-F!oOGJyJ=H%CbxK~Ov00uJ4oFq zOg0;MBm^cfh}Hsp-u5b9EOIR-k7RsaRCkR}%^aDY*-}%cxq_Jh6&^z@946=pB9UwL4SNdV@kd}H7jSSN1p1DNCWJVh(T6>_&_9B~Ngs{= z8b$dDMP6U)YXJAY3? zr0jN^d^fv2HZ!tY)1QhwA}qC;@lRk5)1i5MHRQq16SMF~qAAVAZu>BK^r@`XG`L&S z-(T7-3^%3&xN>V2vyu56o$8GFuv+UnaOd|PQg#E5p>89o*}ZvcmCIRl!W!#d$>wK0 zyUJpywkAnMJ#@o?jjc(rUY~Ee-ha?uf(miRD9iXZsp72+dqB!@ zwZ|t2qDz)Ny5&|e)s5kB(4C)e{f2<6*P-g3s`ZK@nq_(BN>&y4MYSD!qfxljS{J>5 wbBz^stD)3nLboi-tNY0STfZW00dM~HFO)>=|GLcWWdHyG07*qoM6N<$f?-d6)c^nh delta 760 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 delta 1021 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 b(zX8s`scB#p?nNW00000NkvXXu0mjfX9hWQ delta 1400 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 o8acP8vGmJ_B_K;cZi(?9juXRsDPyhe` delta 901 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‡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 M$l_q`KQ5vF56&ZUPyhe` literal 40253 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 diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index bc692c0c4ac49e6fbd3063f246094a0a72bbfe3f..5fb81954480c61d9593a4c326703119ea1cbc7e1 100644 GIT binary patch delta 14936 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 delta 6751 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 diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index feae22713210ba6a729d57a9e691b9ea42a5eee5..04ce722c6194fc2e3d1fd0d964b603a3babbdd0b 100644 GIT binary patch literal 20490 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 literal 6492 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2aaaaf5..d4ef461 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -301,7 +301,7 @@ pub fn run() { #[cfg(target_os = "macos")] { use tauri::menu::{MenuBuilder, SubmenuBuilder}; - let app_menu = SubmenuBuilder::new(app, "Tosse Code") + let app_menu = SubmenuBuilder::new(app, "Flight Deck") .hide() .hide_others() .show_all() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6810355..aa49ab4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Tosse Code", + "productName": "Flight Deck", "version": "0.24.0", "identifier": "com.tosse.desktop", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "Tosse Code", + "title": "Flight Deck", "width": 1200, "height": 800 } diff --git a/src/features/conversation/ConductorSidebar.tsx b/src/features/conversation/ConductorSidebar.tsx index 1c3bc08..894a501 100644 --- a/src/features/conversation/ConductorSidebar.tsx +++ b/src/features/conversation/ConductorSidebar.tsx @@ -264,7 +264,7 @@ function RepoGroup({ + )} + {onFork && ( + + )} + + ); +} + export function MsgUser({ text, queued, images, + onRewind, + onFork, + forkBusy, }: { text: string; queued?: boolean; images?: UserTurnImage[]; + /** When set, show the rewind hover control on this message. */ + onRewind?: () => void; + /** When set, show the fork hover control on this message. */ + onFork?: () => void; + /** The fork on this message is in flight (spin its icon). */ + forkBusy?: boolean; }) { // A `` (and other CLI-injected markers) reaches us AS a user turn, // but the human didn't type it — render the clean card instead of a raw user bubble. @@ -104,6 +179,7 @@ export function MsgUser({ ) : null} {text.trim() ? : null} + ); @@ -843,12 +919,21 @@ function MsgAIGroup({ markers, busy, awaiting, + onRewind, + onFork, + forkBusy, }: { session: string; turnIds: string[]; markers?: RoundMarker[]; busy: boolean; awaiting: boolean; + /** When set, show the rewind hover control on this response. */ + onRewind?: () => void; + /** When set, show the fork hover control on this response. */ + onFork?: () => void; + /** The fork on this response is in flight (spin its icon). */ + forkBusy?: boolean; }) { // Per-turn blocks (shallow-compared: each turn's `blocks` is a stable ref) so we can splice // the markers back at their turn boundaries. With no markers this is just the concatenated @@ -879,6 +964,7 @@ function MsgAIGroup({ )} {lastTurn?.streamingText && } + ); @@ -930,12 +1016,16 @@ export function ConductorThread({ session, scrollRef, onRender, + disableControls = false, }: { session: string; // The stick-to-bottom instance is owned by the parent pane so the composer can // snap the thread to the bottom on send (see ConductorConversation). scrollRef: StickToBottom["scrollRef"]; onRender: StickToBottom["onRender"]; + /** Suppress the per-message rewind/fork hover controls (set by the Flight Deck reply + * modal — a destructive rewind or a background-switching fork is never meant there). */ + disableControls?: boolean; }) { const rawPlan = useTimelineRender(session); // In clean output, a mid-turn marker (control-change bar / injected message) split the @@ -967,6 +1057,100 @@ export function ConductorThread({ const awaiting = state?.awaiting_permission ?? false; const empty = plan.length === 0 && pending.length === 0; + // ── Message hover controls: rewind (in place) + fork (new conversation) ── + // Gated by the display pref, and only on a SETTLED conversation (both operate on the + // on-disk transcript and rewind kills the live process — racing a live turn is unsafe), + // and never in a lightweight surface (the reply modal). `session` is the STABLE conv id. + const messageControls = useDisplay((s) => s.messageControls); + const runningBgTasks = useRunningTaskCount(session); + const showControls = messageControls && !busy && !awaiting && !disableControls; + // { target message id, is-user, its clean composer text, occurrence } — set on click; the + // rewind opens a confirm. `text`/`occurrence` are the fallback locator for a LIVE turn + // (synthetic front id not on disk); occurrence disambiguates identical repeated prompts. + const [rewindTarget, setRewindTarget] = useState< + { id: string; isUser: boolean; text: string | null; occurrence: number | null } | null + >(null); + const [rewinding, setRewinding] = useState(false); + // The message whose fork is in flight (spins its icon; blocks a re-click). The thread + // usually unmounts on success (the branch becomes active), so this mostly matters on error. + const [forkingId, setForkingId] = useState(null); + // The FIRST user prompt is never a cut point — it would empty the branch/conversation + // (the core refuses it too). A user prompt is rewindable/forkable when it's not first. + const firstUserIdx = useMemo(() => plan.findIndex((i) => i.kind === "user"), [plan]); + // A REWIND of a response is a no-op unless a later prompt follows it (nothing to remove); + // a FORK is always valid on a response (it branches everything up to & including it). + const hasUserAfter = useCallback( + (k: number) => plan.slice(k + 1).some((i) => i.kind === "user"), + [plan], + ); + // Whether a user turn was INJECTED mid-work (queued while the agent ran). Such a turn is + // NOT a clean round boundary — cutting there can land mid-response — and clean-output + // already treats it as an in-band marker, so it must not be a rewind/fork target. + const isInjected = useCallback( + (id: string): boolean => + useConversationStore.getState().sessions[session]?.turns[id]?.injectedMidTurn ?? false, + [session], + ); + // The locator for a USER target: its clean text (the image placeholder for an image-only + // turn so the on-disk "[image]" line still matches) plus the occurrence — how many prior + // user turns share its match-key — so an identical repeated prompt cuts at the right one. + const userTarget = useCallback( + (id: string): { text: string; occurrence: number } => { + const s = useConversationStore.getState().sessions[session]; + const turnOf = (tid: string) => { + const t = s?.turns[tid]; + const raw = t?.streamingText ?? ""; + const hasImg = !!(t?.images && t.images.length); + return raw.trim() === "" && hasImg ? "[image]" : raw; + }; + const text = turnOf(id); + const key = promptMatchKey(text); + let occurrence = 0; + for (const e of s?.timeline ?? []) { + if (e.id === id) break; + const t = s?.turns[e.id]; + if (t?.role !== "user") continue; + if (promptMatchKey(turnOf(e.id)) === key) occurrence++; + } + return { text, occurrence }; + }, + [session], + ); + const doRewind = useCallback(async () => { + const target = rewindTarget; + if (!target) return; + setRewinding(true); + try { + await rewindConversation(session, target.id, target.isUser, target.text, target.occurrence); + } catch { + /* the failure was already surfaced as an app error by rewindConversation */ + } finally { + setRewinding(false); + setRewindTarget(null); + } + }, [rewindTarget, session]); + const doFork = useCallback( + async (id: string, isUser: boolean) => { + if (forkingId) return; // guard a double-click (the thread unmounts on switch) + setForkingId(id); + const loc = isUser ? userTarget(id) : null; + try { + await forkConversation(session, id, isUser, loc ? loc.text : null, loc ? loc.occurrence : null); + } catch { + /* already surfaced as an app error by forkConversation */ + } finally { + setForkingId(null); + } + }, + [session, userTarget, forkingId], + ); + // Re-gate the OPEN confirm dialog: if the session goes active (a turn started out-of-band — + // a remote/mobile message, a native injection) after the dialog opened, cancel the pending + // rewind so we never truncate against a live writer / discard the just-arrived turn. + useEffect(() => { + if (rewindTarget && (busy || awaiting)) setRewindTarget(null); + }, [busy, awaiting, rewindTarget]); + return (

@@ -980,8 +1164,9 @@ export function ConductorThread({ aujourd'hui
- {plan.map((item) => { - if (item.kind === "ai") + {plan.map((item, idx) => { + if (item.kind === "ai") { + const aiId = item.ids[item.ids.length - 1]; return ( setRewindTarget({ id: aiId, isUser: false, text: null, occurrence: null }) + : undefined + } + onFork={showControls ? () => doFork(aiId, false) : undefined} + forkBusy={forkingId === aiId} + /> + ); + } + if (item.kind === "user") { + // Not the first prompt, and not a mid-work injection (not a clean boundary). + const usable = showControls && idx !== firstUserIdx && !isInjected(item.id); + return ( + { + const loc = userTarget(item.id); + setRewindTarget({ id: item.id, isUser: true, text: loc.text, occurrence: loc.occurrence }); + } + : undefined + } + onFork={usable ? () => doFork(item.id, true) : undefined} + forkBusy={forkingId === item.id} /> ); - if (item.kind === "user") - return ; + } if (item.kind === "error") return ; if (item.kind === "notice") @@ -1015,6 +1227,37 @@ export function ConductorThread({ )} + setRewindTarget(null)} + onConfirm={doRewind} + > + {rewindTarget?.isUser ? ( + <> + Ce message et tout ce qui suit seront définitivement supprimés de la + conversation. Son texte revient dans la zone de saisie pour que tu puisses le renvoyer. + + ) : ( + <> + Tout ce qui suit cette réponse sera définitivement supprimé de la + conversation. La réponse elle-même est conservée. + + )} + {runningBgTasks > 0 ? ( +
+ + + {runningBgTasks === 1 + ? "Une tâche de fond en cours sera arrêtée." + : `${runningBgTasks} tâches de fond en cours seront arrêtées.`} + +
+ ) : null} +
); } @@ -1072,16 +1315,32 @@ export function TurnRow({ turnId, busy = false, awaiting = false, + onRewind, + onFork, + forkBusy, }: { session: string; turnId: string; /** Session-level flags; MsgAI derives `live` from them + the turn's own status. */ busy?: boolean; awaiting?: boolean; + /** Rewind/fork handlers for a USER turn (passed through to MsgUser's hover controls). */ + onRewind?: () => void; + onFork?: () => void; + forkBusy?: boolean; }) { const turn = useTurn(session, turnId); if (!turn) return null; if (turn.role === "user") - return ; + return ( + + ); return ; } diff --git a/src/features/conversation/ConversationPane.tsx b/src/features/conversation/ConversationPane.tsx index 7f1d95b..db0503a 100644 --- a/src/features/conversation/ConversationPane.tsx +++ b/src/features/conversation/ConversationPane.tsx @@ -28,6 +28,7 @@ export function ConversationPane({ composerRef, onBackgroundClick, inertMentions = false, + disableMessageControls = false, }: { session: string; cwd: string; @@ -37,6 +38,10 @@ export function ConversationPane({ * reply modal, which mounts the pane WITHOUT an editor host, so a mention click * would be a dead link that silently flips the persisted editorOpen flag. */ inertMentions?: boolean; + /** Hide the per-message rewind/fork hover controls. Set by the Flight Deck reply + * modal: a destructive rewind or a background conversation-switching fork is never + * intended from that lightweight surface (and fork's switch is invisible there). */ + disableMessageControls?: boolean; }) { // Toggling "clean output" folds/unfolds every round → big height change. Pass the // EFFECTIVE per-conversation value as the preserve key so the thread re-anchors instead @@ -48,7 +53,12 @@ export function ConversationPane({ {/* Provide the conversation id + live cwd so file mentions in the thread resolve + open in this conversation's editor. */} - + diff --git a/src/features/flightdeck/FlightDeckReplyModal.tsx b/src/features/flightdeck/FlightDeckReplyModal.tsx index bfaeb6b..7c4ff5e 100644 --- a/src/features/flightdeck/FlightDeckReplyModal.tsx +++ b/src/features/flightdeck/FlightDeckReplyModal.tsx @@ -118,6 +118,7 @@ export function FlightDeckReplyModal({ onPromote }: { onPromote: (id: string) => composerRef={composerRef} onBackgroundClick={focusComposerOnClick} inertMentions + disableMessageControls /> diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index ee3b309..954090e 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -194,6 +194,7 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => function DisplayPrefs() { const cleanOutput = useDisplay((s) => s.cleanOutput); const showTaskNotifications = useDisplay((s) => s.showTaskNotifications); + const messageControls = useDisplay((s) => s.messageControls); const set = useDisplay((s) => s.set); return ( @@ -225,6 +226,20 @@ function DisplayPrefs() { onChange={(v) => set({ showTaskNotifications: v })} label="Afficher les notifications de tâche de fond" /> + + Affiche les contrôles au survol des messages (les tiens et ceux de Claude) : + « reprendre ici » (rembobine la conversation à ce point) et + « forker » (branche une nouvelle conversation à ce point).{" "} + Activé par défaut. + + } + checked={messageControls} + onChange={(v) => set({ messageControls: v })} + label="Afficher les contrôles sur les messages" + /> ); } diff --git a/src/ipc/bindings.ts b/src/ipc/bindings.ts index f445c2f..22ffb2c 100644 --- a/src/ipc/bindings.ts +++ b/src/ipc/bindings.ts @@ -74,6 +74,40 @@ async loadSessionContext(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("rewind_conversation", { sessionId, targetId, targetIsUser, targetText, occurrence }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Fork a NEW conversation branched at `target_id`, NON-destructively (the original + * transcript is left intact). Writes the kept history to a fresh transcript beside the + * original and returns it as a [`history::DiskConversation`] (inside [`history::ForkOutcome`]) + * the front turns into a real conversation via `reactivateDiskConversation`. No live session + * is touched — the branch is lazy like any other conversation. Pure file IO off the runtime. + */ +async forkConversation(sessionId: string, targetId: string, targetIsUser: boolean, targetText: string | null, occurrence: number | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("fork_conversation", { sessionId, targetId, targetIsUser, targetText, occurrence }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Load a sub-agent's (`Agent` tool, or a workflow agent) full transcript, * normalized into the same items the live conversation renders. Empty if absent. @@ -1457,6 +1491,12 @@ warnings: string[] } * In both guard cases `content` is empty. */ export type FileContent = { path: string; content: string; too_large: boolean; binary: boolean; size: number } +/** + * The result of a fork ("brancher une nouvelle conversation ici"): the freshly-written + * branch conversation (ready to bring into the app via `reactivateDiskConversation`) and, + * for a USER-message fork, the removed prompt text to seed the new conversation's composer. + */ +export type ForkOutcome = { conversation: DiskConversation; removed_prompt: string | null } /** * Coalesced filesystem change notification for the editor panel: the (de-noised, * debounced) set of paths that changed under the watched working directory. The @@ -1849,6 +1889,21 @@ export type RepoRecord = { id: string; path: string; * Unix ms timestamp the repo was first added. */ added_at: number } +/** + * What a rewind removed, returned to the UI. + */ +export type RewindOutcome = { +/** + * For a USER-message rewind, the text of the removed prompt so the composer can be + * re-seeded with it ("revenir à ce prompt"). `None` for an assistant-message rewind + * (its response is kept; the user just continues with a new message). + */ +removed_prompt: string | null; +/** + * How many transcript lines were dropped. `0` means nothing followed the target — + * a no-op (the conversation already ended there), and the file is left untouched. + */ +removed_lines: number } /** * A search result: which conversation matched, its relevance score, and a short * snippet around the first body hit (empty when only title/excerpt matched). diff --git a/src/ipc/client.ts b/src/ipc/client.ts index fbfe563..2e1ca0f 100644 --- a/src/ipc/client.ts +++ b/src/ipc/client.ts @@ -19,6 +19,7 @@ export type { ExtScope, ExtensionsSnapshot, FileContent, + ForkOutcome, FsChangeEvent, FsEntry, FsWatchErrorEvent, @@ -43,6 +44,7 @@ export type { RemoteControlState, RepoRecord, Result, + RewindOutcome, SearchHit, SessionCommandsEvent, SessionMessageEvent, diff --git a/src/ipc/mock/mockBindings.ts b/src/ipc/mock/mockBindings.ts index 7340cb3..fe33d73 100644 --- a/src/ipc/mock/mockBindings.ts +++ b/src/ipc/mock/mockBindings.ts @@ -28,9 +28,11 @@ import type { PersistedState, PlanUsage, Pong, + ForkOutcome, RemoteControlState, RepoRecord, Result, + RewindOutcome, SearchHit, SessionCommandsEvent, SessionMessageEvent, @@ -409,6 +411,39 @@ export const mockCommands = { return ok({ context_tokens: null, context_window: null }); }, + async rewindConversation( + _sessionId: string, + _targetId: string, + _targetIsUser: boolean, + _targetText: string | null, + _occurrence: number | null, + ): Promise> { + // No on-disk transcript in the browser mock — a benign no-op outcome. + return ok({ removed_prompt: null, removed_lines: 0 }); + }, + + async forkConversation( + _sessionId: string, + _targetId: string, + _targetIsUser: boolean, + _targetText: string | null, + _occurrence: number | null, + ): Promise> { + // No on-disk transcript in the browser mock — echo a placeholder branch row. + return ok({ + conversation: { + session_id: "mock-fork", + cwd: "/mock", + repo_root: "/mock", + git_branch: null, + title: null, + excerpt: "fork (mock)", + mtime_ms: 0, + }, + removed_prompt: null, + }); + }, + async listDiskConversations(): Promise> { // A representative set so the history panel renders real-shaped rows in // dev/Playwright (two repos, one orphan-style worktree conversation). diff --git a/src/store/conversationsStore.ts b/src/store/conversationsStore.ts index a9c37ed..da33115 100644 --- a/src/store/conversationsStore.ts +++ b/src/store/conversationsStore.ts @@ -21,7 +21,7 @@ import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { uid } from "../util/id"; import { commands } from "../ipc/client"; -import type { ConversationItem, ConversationRecord, DiskConversation, PermissionMode, RepoRecord } from "../ipc/client"; +import type { ConversationItem, ConversationRecord, DiskConversation, ForkOutcome, PermissionMode, RepoRecord, RewindOutcome } from "../ipc/client"; import type { ReminderKind } from "../agent/status"; import { useConversationStore } from "./conversationStore"; import { useBackgroundTasksStore } from "./backgroundTasksStore"; @@ -31,7 +31,7 @@ import { useLastMessageSummaryStore } from "./lastMessageSummary"; import { useAppErrors } from "./appErrors"; import { getCachedWindow, clearCachedWindow, clearAllCachedWindows } from "./contextWindowCache"; import { clearTodoBarOpen, clearAllTodoBarOpen } from "./todoBarUi"; -import { clearComposerDraft, clearAllComposerDrafts } from "./composerDrafts"; +import { clearComposerDraft, clearAllComposerDrafts, useComposerDrafts } from "./composerDrafts"; import { clearComposerAttachments, clearAllComposerAttachments, @@ -1181,6 +1181,87 @@ export async function reloadConversationHistory(convId: string): Promise { historyLoaded.add(convId); // the select-time loader is now satisfied for this run } +/** + * Rewind a conversation IN PLACE to a chosen message ("reprendre à partir d'ici") — + * destructive: everything after the cut is dropped from Claude's on-disk transcript. + * + * `targetIsUser` selects the semantics (see `rewind_conversation` in the core): + * - a USER message → that prompt and everything after it are removed, and the prompt + * text is put back in the composer draft so it can be edited and re-sent; + * - a CLAUDE message → its whole response is kept and everything AFTER it is removed. + * + * Order matters: the live `claude` process is stopped FIRST (it holds the full history + * in memory and would rewrite the transcript on its next turn, undoing the cut), THEN + * the transcript is truncated, THEN the timeline is rebuilt from the shortened file. The + * next message re-spawns `--resume` on the truncated transcript (VERIFIED: resume honours + * the truncation). Returns the outcome, or `null` if the conversation has no session yet. + */ +export async function rewindConversation( + convId: string, + targetId: string, + targetIsUser: boolean, + targetText: string | null, + occurrence: number | null, +): Promise { + const conv = useConversationsStore.getState().conversations.find((c) => c.id === convId); + if (!conv?.sessionId) return null; + // 1. Kill the live process AND WAIT for it to be fully reaped (stopConversationSession → + // stop_session → shutdown_and_wait) so nothing re-writes the transcript from its + // in-memory state while / after we truncate. + await stopConversationSession(convId); + // 2. Truncate the on-disk transcript at the target. `targetText` + `occurrence` are the + // fallback locator for a LIVE turn (its synthetic front id isn't on disk — resolve_cut), + // occurrence disambiguating identical repeated prompts. + const res = await commands.rewindConversation(conv.sessionId, targetId, targetIsUser, targetText, occurrence); + if (res.status !== "ok") { + useAppErrors.getState().pushError("Impossible de rembobiner la conversation.", res.error); + throw new Error(res.error); + } + // 3. Rebuild the timeline from the (now shorter) transcript. + await reloadConversationHistory(convId); + // 4. A user rewind hands the removed prompt back to the composer so it can be edited + // and re-sent ("revenir à ce prompt, la main repart à Claude"). + if (res.data.removed_prompt) { + useComposerDrafts.getState().setDraft(convId, res.data.removed_prompt); + } + return res.data; +} + +/** + * Fork a NEW conversation branched at a message ("forker à partir d'ici") — NON-destructive: + * the original conversation is left untouched. The core copies the transcript up to the cut + * into a fresh session file; we bring it into the app as a real conversation (which selects + * it and loads its history), and seed its composer with the removed prompt for a user fork. + * Returns the new conversation's stable id, or `null` if the source has no session yet. + */ +export async function forkConversation( + convId: string, + targetId: string, + targetIsUser: boolean, + targetText: string | null, + occurrence: number | null, +): Promise { + const conv = useConversationsStore.getState().conversations.find((c) => c.id === convId); + if (!conv?.sessionId) return null; + const res = await commands.forkConversation(conv.sessionId, targetId, targetIsUser, targetText, occurrence); + if (res.status !== "ok") { + useAppErrors.getState().pushError("Impossible de forker la conversation.", res.error); + throw new Error(res.error); + } + const outcome: ForkOutcome = res.data; + // Reuse the disk-conversation import path (creates the row, auto-selects it → its history + // loads via ConductorConversation's effect). Name it after the source, marked as a branch. + const branch: DiskConversation = { + ...outcome.conversation, + title: `${conv.name} (fork)`, + }; + const newId = reactivateDiskConversation(branch); + if (outcome.removed_prompt) { + useComposerDrafts.getState().setDraft(newId, outcome.removed_prompt); + } + return newId; +} + /** * Drop ALL data — persisted (the core's db) and in-memory — leaving an empty * slate (no default conversation). Wired to the Settings "drop all" button; also diff --git a/src/store/display.ts b/src/store/display.ts index 2ea4400..d64586c 100644 --- a/src/store/display.ts +++ b/src/store/display.ts @@ -58,6 +58,12 @@ export interface DisplayPrefs { * clean render (SpecialMessageCard) is kept, just gated: flip this on to see them * again. Read by {@link SpecialMessageCard}. */ showTaskNotifications: boolean; + + /** Show the hover controls on conversation messages — "reprendre à partir d'ici" (rewind + * the conversation in place) and "forker" (branch a new conversation at this message), + * offered on both the user's and Claude's messages. ON by default. Off → messages have no + * hover controls. Read by {@link MessageActions} (via the conversation thread). */ + messageControls: boolean; } // Off by default: the transcript shows everything inline as before. The user opts in @@ -71,6 +77,7 @@ const DEFAULTS: DisplayPrefs = { fleetBannerConversation: true, alertOnBackgroundWait: true, showTaskNotifications: false, + messageControls: true, }; function load(): DisplayPrefs { @@ -109,6 +116,7 @@ export const useDisplay = create((set) => ({ fleetBannerConversation: patch.fleetBannerConversation ?? s.fleetBannerConversation, alertOnBackgroundWait: patch.alertOnBackgroundWait ?? s.alertOnBackgroundWait, showTaskNotifications: patch.showTaskNotifications ?? s.showTaskNotifications, + messageControls: patch.messageControls ?? s.messageControls, }; save(next); return next; diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index 94b902f..ed8c81a 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -119,7 +119,40 @@ /* min-width:0 lets these flex items shrink below their content size, so wide children (diffs, code blocks) scroll INTERNALLY instead of forcing the whole thread wider than its max-width (horizontal overflow / runaway scrollbar). */ -.cv-msg { display:flex; gap:11px; min-width:0; } +.cv-msg { display:flex; gap:11px; min-width:0; position:relative; } + +/* Hover controls on a message — bare icons (no frame, no background), sitting just BELOW + the message, that fade in on hover. Anchored inside the bubble/body (position:relative + on those), bottom-left, so they align with the message content. Reveal-on-hover pattern + (opacity + pointer-events), also shown on keyboard focus so they're reachable without a + pointer. Icons are dim by default and brighten on hover — nothing but the glyphs on the + dark thread. */ +.cv-bubble, .cv-aibody { position:relative; } +.cv-msg-actions { + position:absolute; left:0; bottom:-20px; z-index:4; + display:inline-flex; gap:5px; + opacity:0; pointer-events:none; transition:opacity .12s ease; +} +.cv-msg:hover .cv-msg-actions, +.cv-msg-actions:focus-within { opacity:1; pointer-events:auto; } +.cv-msg-action { + display:inline-flex; align-items:center; justify-content:center; + width:22px; height:22px; padding:0; + border:none; background:transparent; color:var(--wf-tx-xlo); + cursor:pointer; border-radius:5px; transition:color .12s ease; +} +.cv-msg-action:hover { color:var(--wf-tx-hi); } +/* Visible keyboard focus ring (not colour-only): these are real, keyboard-reachable actions. */ +.cv-msg-action:focus-visible { outline:2px solid var(--wf-accent); outline-offset:1px; color:var(--wf-tx-hi); } +.cv-msg-action[disabled] { cursor:default; } +.cv-msg-action .wf-ico { width:15px; height:15px; } +/* Fork in flight: spin the icon so a slow branch copy isn't a dead click. */ +.cv-msg-action.is-busy { color:var(--wf-accent); } +.cv-msg-action.is-busy .wf-ico { animation:cv-spin .8s linear infinite; } +@keyframes cv-spin { to { transform:rotate(360deg); } } +/* Warning line inside the rewind confirm when background tasks will be terminated. */ +.cv-rewind-warn { display:flex; align-items:center; gap:7px; margin-top:12px; color:var(--wf-wait); font-size:12px; } +.cv-rewind-warn .wf-ico { width:15px; height:15px; flex:0 0 auto; } /* Sub-agent transcript (inline expand + popover): the same generous breathing room between messages as the main thread — without it the prompt sits glued to the first reply. Used by and the inline live sub-thread. */ diff --git a/src/ui/conductor-wirekit.css b/src/ui/conductor-wirekit.css index 4e7448d..c291d9d 100644 --- a/src/ui/conductor-wirekit.css +++ b/src/ui/conductor-wirekit.css @@ -119,10 +119,14 @@ as a thin separation ring first, then the violet — that separation is essential because blue (#7aa2e3) and violet (#a78bfa) are close hues and would otherwise blend into one indistinct blob. Per-colour rules (0,3,0 specificity) so each beats its .wf-dot. - base regardless of source order. */ -.wf-dot.wait.bgring { box-shadow: 0 0 0 2px var(--wf-wait-bg), 0 0 0 3.5px #a78bfa; } -.wf-dot.att.bgring { box-shadow: 0 0 0 2px var(--wf-att-bg), 0 0 0 3.5px #a78bfa; } -.wf-dot.err.bgring { box-shadow: 0 0 0 2px var(--wf-err-bg), 0 0 0 3.5px #a78bfa; } + base regardless of source order. + Spreads are WHOLE pixels (2px + 4px, not the old 2px + 3.5px): a fractional spread on + the odd 7px core rasterised the violet ring thicker on one side than the other at some + zoom/DPI levels, so the blue core looked off-centre inside the violet. Integer spreads + round identically on every edge → the ring stays perfectly concentric. */ +.wf-dot.wait.bgring { box-shadow: 0 0 0 2px var(--wf-wait-bg), 0 0 0 4px #a78bfa; } +.wf-dot.att.bgring { box-shadow: 0 0 0 2px var(--wf-att-bg), 0 0 0 4px #a78bfa; } +.wf-dot.err.bgring { box-shadow: 0 0 0 2px var(--wf-err-bg), 0 0 0 4px #a78bfa; } @keyframes wf-pulse { 0%,100%{ opacity:1 } 50%{ opacity:.35 } } .wf-dot.pulse { animation: wf-pulse 1.8s ease-in-out infinite; } From 3cf726f7fc593495bfb264919e5a7f82e791cc97 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Sat, 4 Jul 2026 11:28:28 +0200 Subject: [PATCH 22/91] =?UTF-8?q?fix(conversation):=20l'aper=C3=A7u=20du?= =?UTF-8?q?=20dernier=20message=20ignore=20les=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les messages (injectés par le CLI, Claude→lui-même) arrivent avec role:user et étaient ramassés comme « dernier message envoyé » dans l'aperçu (pin conversation + peek Flight Deck) et le rappel du composer. selectUserMessageHistory les exclut désormais via parseSpecialMessage — même détection que le rendu en carte dédiée (jamais une bulle user). Anti-faux-positif préservé : un message qui mentionne seulement le tag est conservé. Co-Authored-By: Claude Opus 4.8 --- src/store/conversationStore.ts | 11 +++++++++-- src/store/userMessageHistory.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/store/conversationStore.ts b/src/store/conversationStore.ts index 3e0c00c..25ee01e 100644 --- a/src/store/conversationStore.ts +++ b/src/store/conversationStore.ts @@ -41,6 +41,7 @@ import type { } from "./types"; import { isBackgroundAgentInput, isDetachedAgentAck } from "../agent/subagentMeta"; import { latestTodosInBlocks, todoSummary } from "./todos"; +import { parseSpecialMessage } from "../features/conversation/specialMessage"; const connectingState: SessionStatePayload = { busy: false, @@ -850,8 +851,10 @@ export const useTodoSummary = (session: string): TodoSummary => * The user's OWN messages in a session, chronological — the root-level `role:"user"` * turns in timeline order. Blank messages are dropped and consecutive duplicates * collapsed (like a shell history); sub-agent (Task) user turns - * (`parentToolUseId !== null`) are excluded. Pure (no hook, no memo) so it is - * unit-testable; the hook below memoises it. */ + * (`parentToolUseId !== null`) are excluded, as are CLI-injected special messages + * (`` &c. — Claude talking to itself, not the human), which arrive + * as `role:"user"` turns but are NOT something the user sent. Pure (no hook, no memo) + * so it is unit-testable; the hook below memoises it. */ export function selectUserMessageHistory(entry: SessionEntry | undefined): string[] { if (!entry) return EMPTY_STRINGS; const out: string[] = []; @@ -861,6 +864,10 @@ export function selectUserMessageHistory(entry: SessionEntry | undefined): strin if (!turn || turn.role !== "user" || turn.parentToolUseId !== null) continue; const text = turn.streamingText; if (!text.trim()) continue; + // Skip CLI-injected markers (task-notification…): they're rendered as a dedicated + // card, never a user bubble — so they must not count as "the user's last message" + // in the preview/pin or the composer's recall history. + if (parseSpecialMessage(text)) continue; if (out.length && out[out.length - 1] === text) continue; // collapse consecutive dups out.push(text); } diff --git a/src/store/userMessageHistory.test.ts b/src/store/userMessageHistory.test.ts index 0dce4a2..9e32618 100644 --- a/src/store/userMessageHistory.test.ts +++ b/src/store/userMessageHistory.test.ts @@ -71,6 +71,19 @@ describe("selectUserMessageHistory", () => { expect(selectUserMessageHistory(e)).toEqual(["real"]); }); + it("excludes CLI-injected markers but keeps prose that only mentions the tag", () => { + // A real injection OPENS on the tag → dropped (it's Claude talking to itself). + const notif = "\ncompleted\n"; + // Prose that merely references the tag never opens on it → a genuine user message, kept. + const mention = "how do I read a block?"; + const e = entry([turnLine("u1"), turnLine("tn"), turnLine("u2")], { + u1: userTurn("u1", "real ask"), + tn: userTurn("tn", notif), + u2: userTurn("u2", mention), + }); + expect(selectUserMessageHistory(e)).toEqual(["real ask", mention]); + }); + it("ignores non-turn timeline entries and assistant turns", () => { const e = entry( [turnLine("u1"), { kind: "notice", id: "n1" } as TimelineEntry, turnLine("a1")], From 2084c39c5bc9b3c2e528ee080c39d5dd5186da02 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Sat, 4 Jul 2026 12:04:49 +0200 Subject: [PATCH 23/91] =?UTF-8?q?chore(brand):=20renomme=20les=20builds=20?= =?UTF-8?q?de=20dev/test=20en=20=C2=AB=20Flight=20Deck=20=C2=BB=20(identif?= =?UTF-8?q?iants=20inchang=C3=A9s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev-build.conf.json productName « Tosse Code dev build » → « Flight Deck dev build ». /build-dev + /build-app : noms affichés des bundles de test alignés (Flight Deck dev build / FlightDeck ). Identifiants com.tosse.desktop.dev / . CONSERVÉS (dossiers de données de test inchangés). Références à l'app de prod /Applications/Tosse Code.app gardées (install réelle d'Alexandre). Co-Authored-By: Claude Opus 4.8 --- .claude/skills/build-app/SKILL.md | 8 +++--- .claude/skills/build-dev/SKILL.md | 10 +++---- src-tauri/dev-build.conf.json | 2 +- src/features/conversation/LastMessagePin.tsx | 4 ++- src/features/conversation/userText.tsx | 11 ++++++++ src/features/flightdeck/LastMessagePeek.tsx | 5 +++- .../settings/SettingsPanel.module.css | 13 +-------- src/ui/conductor-conversation.css | 28 +++++++++++-------- 8 files changed, 45 insertions(+), 36 deletions(-) diff --git a/.claude/skills/build-app/SKILL.md b/.claude/skills/build-app/SKILL.md index 9a3e66f..84334cf 100644 --- a/.claude/skills/build-app/SKILL.md +++ b/.claude/skills/build-app/SKILL.md @@ -1,7 +1,7 @@ --- name: build-app description: | - Build et lance l'app Tosse Code depuis le worktree courant pour la tester comme une vraie app, AVEC SES DONNÉES ISOLÉES de la prod. Utilise ce skill quand : + Build et lance l'app Flight Deck depuis le worktree courant pour la tester comme une vraie app, AVEC SES DONNÉES ISOLÉES de la prod. Utilise ce skill quand : - L'utilisateur tape `/build-app` - L'utilisateur dit « build l'app », « compile l'app pour tester », « lance-moi l'app de cette feature », « je veux tester l'app » - Tu as fini (ou veux essayer) une feature et tu veux la voir tourner dans une vraie fenêtre, pas juste en tests unitaires @@ -10,7 +10,7 @@ description: | # Build-app — Builder et lancer une app de test isolée -Ce skill produit un bundle macOS de l'app **propre à la feature courante**, nommé `TosseCode `, et le lance. Il garantit que ce build **ne touche jamais les données de la prod**. +Ce skill produit un bundle macOS de l'app **propre à la feature courante**, nommé `FlightDeck `, et le lance. Il garantit que ce build **ne touche jamais les données de la prod**. ## ⚠️ L'invariant à ne jamais casser : identifiant distinct @@ -45,7 +45,7 @@ Crée un fichier de config **temporaire** qui sera fusionné par-dessus `tauri.c ```json { "$schema": "https://schema.tauri.app/config/2", - "productName": "TosseCode ", + "productName": "FlightDeck ", "identifier": "com.tosse.desktop.", "bundle": { "createUpdaterArtifacts": false } } @@ -66,7 +66,7 @@ pnpm tauri build --config src-tauri/target/build-overlay-.json Le bundle sort dans `src-tauri/target/release/bundle/macos/` sous le `productName` : ```bash -open "src-tauri/target/release/bundle/macos/TosseCode .app" +open "src-tauri/target/release/bundle/macos/FlightDeck .app" ``` Comme chaque feature a un `productName` distinct, les bundles ne s'écrasent pas entre eux. diff --git a/.claude/skills/build-dev/SKILL.md b/.claude/skills/build-dev/SKILL.md index 67078d0..bf6d1b6 100644 --- a/.claude/skills/build-dev/SKILL.md +++ b/.claude/skills/build-dev/SKILL.md @@ -1,16 +1,16 @@ --- name: build-dev description: | - Build et lance l'app **Tosse Code dev build** depuis le worktree PRINCIPAL sur la branche `dev`, avec ses données ISOLÉES de la prod. Utilise ce skill quand : + Build et lance l'app **Flight Deck dev build** depuis le worktree PRINCIPAL sur la branche `dev`, avec ses données ISOLÉES de la prod. Utilise ce skill quand : - L'utilisateur tape `/build-dev` - L'utilisateur dit « build la dev », « build l'app sur dev », « compile dev pour tester », « je veux tester la dev » - Tu veux voir tourner l'état courant de `dev` (p. ex. juste après un `/land`) dans une vraie fenêtre, pas seulement en tests unitaires - À la différence de `/build-app` (qui build UNE feature dans SON worktree, avec un identifiant par feature), ce skill build le **worktree principal** sur `dev` sous une identité FIXE `Tosse Code dev build` (`com.tosse.desktop.dev`) — jamais l'app de prod `/Applications/Tosse Code.app`. + À la différence de `/build-app` (qui build UNE feature dans SON worktree, avec un identifiant par feature), ce skill build le **worktree principal** sur `dev` sous une identité FIXE `Flight Deck dev build` (`com.tosse.desktop.dev`) — jamais l'app de prod `/Applications/Tosse Code.app`. --- # Build-dev — Builder et lancer l'app de test « dev » -Ce skill produit un bundle macOS de l'app depuis le **worktree principal, sur la branche `dev`** (l'état intégré du projet, pas une feature isolée), nommé **`Tosse Code dev build`**, et le lance. Il garantit que ce build **ne touche jamais les données de la prod**. +Ce skill produit un bundle macOS de l'app depuis le **worktree principal, sur la branche `dev`** (l'état intégré du projet, pas une feature isolée), nommé **`Flight Deck dev build`**, et le lance. Il garantit que ce build **ne touche jamais les données de la prod**. C'est l'équivalent de `/build-app` mais pour l'**arbre de travail principal** : on teste `dev` tel qu'il est (typiquement après avoir posé une ou plusieurs features via `/land`), pas une branche de feature dans un worktree. @@ -20,7 +20,7 @@ Alexandre **dogfoode** : son app de prod est `/Applications/Tosse Code.app` (`id `tauri build` réutilise par défaut **le même `identifier`** → la **même base SQLite** → un build lancé tel quel **écrase / pollue les conversations réelles**. -La parade est déjà versionnée : le fichier **`src-tauri/dev-build.conf.json`** fixe `productName: "Tosse Code dev build"` et `identifier: "com.tosse.desktop.dev"`. Les données de ce build vivent donc dans `~/Library/Application Support/com.tosse.desktop.dev/` — un dossier séparé, partagé par tous les builds « dev » (c'est voulu : un environnement de test « dev » persistant). **Zéro risque pour la prod.** +La parade est déjà versionnée : le fichier **`src-tauri/dev-build.conf.json`** fixe `productName: "Flight Deck dev build"` et `identifier: "com.tosse.desktop.dev"`. Les données de ce build vivent donc dans `~/Library/Application Support/com.tosse.desktop.dev/` — un dossier séparé, partagé par tous les builds « dev » (c'est voulu : un environnement de test « dev » persistant). **Zéro risque pour la prod.** ## Étape 1 — Vérifier qu'on est bien sur le worktree principal, sur `dev` @@ -57,7 +57,7 @@ pnpm tauri build --config src-tauri/dev-build.conf.json Le bundle sort dans `src-tauri/target/release/bundle/macos/` sous le `productName` : ```bash -open "src-tauri/target/release/bundle/macos/Tosse Code dev build.app" +open "src-tauri/target/release/bundle/macos/Flight Deck dev build.app" ``` ## Étape 5 — Rapporter diff --git a/src-tauri/dev-build.conf.json b/src-tauri/dev-build.conf.json index 67dcc40..d32d3aa 100644 --- a/src-tauri/dev-build.conf.json +++ b/src-tauri/dev-build.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Tosse Code dev build", + "productName": "Flight Deck dev build", "identifier": "com.tosse.desktop.dev", "bundle": { "createUpdaterArtifacts": false diff --git a/src/features/conversation/LastMessagePin.tsx b/src/features/conversation/LastMessagePin.tsx index c70e86b..e958f0e 100644 --- a/src/features/conversation/LastMessagePin.tsx +++ b/src/features/conversation/LastMessagePin.tsx @@ -15,6 +15,7 @@ import { Ico } from "../../ui/kit"; import { useDisplay } from "../../store/display"; import { useLastMessageSummary, summaryPreview } from "../../store/lastMessageSummary"; import { useUserMessageHistory } from "../../store/conversationStore"; +import { userMessagePreviewText } from "./userText"; export function LastMessagePin({ session, @@ -29,7 +30,8 @@ export function LastMessagePin({ const enabled = useDisplay((s) => s.showLastMessagePreview); const summary = useLastMessageSummary(session); const history = useUserMessageHistory(session); - const last = history.length ? history[history.length - 1] : undefined; + // Clean the raw last message (a slash-command's `` wrapper) before truncating. + const last = history.length ? userMessagePreviewText(history[history.length - 1]) : undefined; // Prefer the (nicer) live summary; fall back to a truncation of the persisted last // message so the pin survives a reload where the live summary is gone. const text = summary ?? (last ? summaryPreview(last) : undefined); diff --git a/src/features/conversation/userText.tsx b/src/features/conversation/userText.tsx index 6795cf1..4ccfc7a 100644 --- a/src/features/conversation/userText.tsx +++ b/src/features/conversation/userText.tsx @@ -28,6 +28,17 @@ export function parseSlashCommand(text: string): { command: string; args: string return { command, args }; } +/** The clean one-line form of a user message for previews (pin / Flight Deck peek): a + * slash-command collapses to `/command args`, dropping the CLI's `… + * ` wrapper that would otherwise leak into the preview as raw tags. Any + * other message is returned unchanged. Twin of `UserText`'s chip, for the plain-text + * surfaces that can't render a chip. */ +export function userMessagePreviewText(text: string): string { + const cmd = parseSlashCommand(text); + if (!cmd) return text; + return cmd.args ? `${cmd.command} ${cmd.args}` : cmd.command; +} + /** A user message's text: a slash-command shows as a clean chip (the `` wrapper * is dropped), everything else renders as-is. */ export function UserText({ text }: { text: string }) { diff --git a/src/features/flightdeck/LastMessagePeek.tsx b/src/features/flightdeck/LastMessagePeek.tsx index 8a1a3aa..bacbfb6 100644 --- a/src/features/flightdeck/LastMessagePeek.tsx +++ b/src/features/flightdeck/LastMessagePeek.tsx @@ -11,13 +11,16 @@ import { useRef, useState } from "react"; import { Ico } from "../../ui/kit"; import { useUserMessageHistory } from "../../store/conversationStore"; +import { userMessagePreviewText } from "../conversation/userText"; import { CardPopover } from "./CardPopover"; export function LastMessagePeek({ convId, summary }: { convId: string; summary: string }) { const [open, setOpen] = useState(false); const btnRef = useRef(null); const history = useUserMessageHistory(convId); - const full = history.length ? history[history.length - 1] : summary; + // Clean the raw last message: a slash-command's `` wrapper must not leak + // into the peek as raw tags. + const full = history.length ? userMessagePreviewText(history[history.length - 1]) : summary; return ( <> diff --git a/src/features/settings/SettingsPanel.module.css b/src/features/settings/SettingsPanel.module.css index f56f6ff..a2c3e54 100644 --- a/src/features/settings/SettingsPanel.module.css +++ b/src/features/settings/SettingsPanel.module.css @@ -134,7 +134,7 @@ background: #ffffff06; color: var(--text-primary); } -/* Active tab — coral-tinted fill, coral icon, and a glowing left accent bar. */ +/* Active tab — coral-tinted fill and coral icon. */ .railItem[data-on] { background: linear-gradient(90deg, #2a1c15, #1a1512 80%); border-color: #43301f; @@ -143,17 +143,6 @@ .railItem[data-on] :global(.wf-ico) { color: var(--accent); } -.railItem[data-on]::before { - content: ""; - position: absolute; - left: 0; - top: 8px; - bottom: 8px; - width: 2.5px; - border-radius: 2px; - background: var(--accent); - box-shadow: 0 0 10px -1px var(--accent); -} .content { flex: 1; diff --git a/src/ui/conductor-conversation.css b/src/ui/conductor-conversation.css index fd3eff5..67c6ccd 100644 --- a/src/ui/conductor-conversation.css +++ b/src/ui/conductor-conversation.css @@ -155,25 +155,29 @@ /* min-width:0 lets these flex items shrink below their content size, so wide children (diffs, code blocks) scroll INTERNALLY instead of forcing the whole thread wider than its max-width (horizontal overflow / runaway scrollbar). */ -.cv-msg { display:flex; gap:11px; min-width:0; position:relative; } - -/* Hover controls on a message — bare icons (no frame, no background), sitting just BELOW - the message, that fade in on hover. Anchored inside the bubble/body (position:relative - on those), bottom-left, so they align with the message content. Reveal-on-hover pattern - (opacity + pointer-events), also shown on keyboard focus so they're reachable without a - pointer. Icons are dim by default and brighten on hover — nothing but the glyphs on the - dark thread. */ +/* padding-bottom reserves a full-width strip BELOW the message content that is still PART of + `.cv-msg` — so hovering that strip (where the controls sit) triggers `.cv-msg:hover` and + there is NO dead gap between the block and the button row when moving down to click them + (the reveal was previously lost the moment the cursor left the block into the inter-message + gap). Also gives the thread a touch more air. */ +.cv-msg { display:flex; gap:11px; min-width:0; position:relative; padding-bottom:8px; } + +/* Hover controls on a message — bare icons (no frame, no background), sitting just BELOW the + message, revealed on hover. Anchored FLUSH at the message bottom (`top:100%`, no gap) inside + the bubble/body (position:relative on those), bottom-left, aligned with the message content + — so the reserved `.cv-msg` bottom strip and the buttons form one continuous hover zone. + Icons are dim by default and brighten on hover — nothing but the glyphs on the dark thread. */ .cv-bubble, .cv-aibody { position:relative; } .cv-msg-actions { - position:absolute; left:0; bottom:-20px; z-index:4; - display:inline-flex; gap:5px; + position:absolute; left:0; top:100%; z-index:4; + display:inline-flex; gap:10px; padding-top:2px; opacity:0; pointer-events:none; transition:opacity .12s ease; } .cv-msg:hover .cv-msg-actions, .cv-msg-actions:focus-within { opacity:1; pointer-events:auto; } .cv-msg-action { display:inline-flex; align-items:center; justify-content:center; - width:22px; height:22px; padding:0; + width:24px; height:24px; padding:0; border:none; background:transparent; color:var(--wf-tx-xlo); cursor:pointer; border-radius:5px; transition:color .12s ease; } @@ -181,7 +185,7 @@ /* Visible keyboard focus ring (not colour-only): these are real, keyboard-reachable actions. */ .cv-msg-action:focus-visible { outline:2px solid var(--wf-accent); outline-offset:1px; color:var(--wf-tx-hi); } .cv-msg-action[disabled] { cursor:default; } -.cv-msg-action .wf-ico { width:15px; height:15px; } +.cv-msg-action .wf-ico { width:16px; height:16px; } /* Fork in flight: spin the icon so a slow branch copy isn't a dead click. */ .cv-msg-action.is-busy { color:var(--wf-accent); } .cv-msg-action.is-busy .wf-ico { animation:cv-spin .8s linear infinite; } From d2e243181f9c9f3316aca12415987fa0ca2141a5 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Sat, 4 Jul 2026 12:21:27 +0200 Subject: [PATCH 24/91] chore(release): v0.25.0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ CLAUDE.md | 16 +++++++++++----- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e14dce3..1e40c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ GitHub, que l'app affiche telle quelle. Le bloc d'instructions d'installation (a le marqueur ``) est ajouté par `release.yml` et reste **seulement** sur la page GitHub — il n'apparaît pas dans l'app. +## v0.25.0 + +- L'app s'appelle désormais **Flight Deck** et arbore un nouveau logo. +- Nouveaux contrôles au survol des messages : reprendre la conversation à partir d'un message (rewind) ou repartir dans une nouvelle branche (fork). +- Un pin flottant affiche votre dernier message envoyé en haut du fil. +- Les messages conservent leurs retours à la ligne, et l'aperçu du dernier message ignore les notifications internes. +- Correction : un jeton d'usage périmé ne masque plus le solde issu du Keychain. + ## v0.24.0 - Flight Deck : cartes cliquables avec pop-ups (conversation, dernier message, to-do) et vue d'ensemble enrichie de la flotte d'agents. diff --git a/CLAUDE.md b/CLAUDE.md index 4e7a278..f7e544f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,10 @@ Task status flow: `Backlog → À faire → En cours → Review → Fait` Desktop app pour piloter Claude Code. La **vision**, le **périmètre par phases**, le **principe directeur** et les **décisions structurantes** sont au niveau du contexte projet (Tosse Code). Ici : la stack concrète et le « comment on construit ». +## Nom affiché « Flight Deck » vs identité interne `tosse-code` + +⚠️ Depuis juillet 2026, le **nom AFFICHÉ de l'app est « Flight Deck »** (productName Tauri + titre de fenêtre, menu app macOS, wordmark UI `TosseMark`, 3 messages TCC `Info.plist`, dialogues Réglages/MAJ/notifs, `releaseName`), avec un **nouveau logo** « avion cyan + souffle réacteur corail » (`public/tosse.svg`, `src/ui/TosseMark.tsx`, `src-tauri/app-icon.svg` + icônes régénérées via `pnpm tauri icon`). **C'est un rebrand DISPLAY-ONLY** : l'**identité technique reste `tosse-code`** et NE DOIT PAS changer — identifiant `com.tosse.desktop`, crate/package `tosse-code`, repo GitHub `Alex375/tosse-code`, certificat « Tosse Code Self-Signed », projet CRM « Tosse Code », et les filenames internes `public/tosse.svg` + composant `TosseMark`. **NE PAS « corriger » la divergence productName ↔ identifiant/crate** : elle est VOLONTAIRE (changer l'identifiant = nouveau dossier `~/Library/Application Support/…` = perte des conversations + re-grant TCC global). La **vue de gestion d'agents garde aussi le nom « Flight Deck »** (l'app == sa vue phare). Piège updater : les installs déjà en place restent le fichier `Tosse Code.app` (remplacement en place par l'updater) tout en affichant « Flight Deck » ; un nouvel install `.dmg` = `Flight Deck.app`. + ## Stack - **Shell desktop** : Tauri 2 (webview de l'OS, pas de Chromium embarqué). @@ -131,10 +135,10 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell - `fs/` : `src-tauri/src/fs/mod.rs` = SEUL service filesystem de l'éditeur. `read_dir`, `read_file` (garde binaire/trop-gros >2 Mio), `write_file`, `read_image` (base64 des octets, garde `MAX_FILE_BYTES` 16 Mio — réutilisé par le viewer ET l'attachement d'images du composeur), `FsWatcher` (notify, debounce ~150 ms, filtre `.git/node_modules/target/dist/build/…`). - `usage/` : `src-tauri/src/usage/mod.rs` = SEUL service credentials OAuth + endpoint d'usage. `GET https://api.anthropic.com/api/oauth/usage` (% forfait 5h/7j). Token depuis `~/.claude/.credentials.json` → Keychain macOS (`/usr/bin/security`). **Lecture seule** (jamais de refresh ni d'écriture de credentials). - `terminal/` : `src-tauri/src/terminal/mod.rs` = SEUL service PTY. Invariants à ne PAS régresser : (1) writer PTY **hors du lock global** (anti-hang au quit) ; (2) teardown tue le GROUPE de process (`kill(-pid, SIGKILL)`, shell = leader `setsid`) — anti-orphelins ; (3) instances xterm **persistantes par conversation** (survivent fermeture panneau + switch conv), gérées par `termManager.ts` hors React ; (4) Monaco + TerminalView **lazy-loadés** (code-split, hors bundle de démarrage) ; (5) nettoyage xterm câblé via `cleanup.ts` sur `removeConversation`/`removeRepo`/`wipeAllData`. - - Surface IPC (tauri-specta) : `spawn_session`, `send_message` (text + `images: Vec` base64), `answer_permission`, `set_permission_mode`, `interrupt_session`, `stop_session`, `stop_task`, `load_persisted_state`, `upsert_repo`, `delete_repo`, `upsert_conversation`, `delete_conversation`, `set_active_conversation`, `wipe_all_data`, `list_worktrees`, `worktree_status`, `create_worktree`, `remove_worktree`, `read_dir`, `read_file`, `read_image`, `write_file`, `watch_dir`, `unwatch_dir`, `load_subagent_transcript`, `load_workflow_run`, `load_workflow_journal`, `load_workflow_phases`, `read_task_output`, `request_user_attention(critical:bool)`, `get_plan_usage`, `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `generate_conversation_title`, `set_remote_control`. Events : `session_state`, `session_message`, `session_permission`, `FsChangeEvent`, `FsWatchErrorEvent`, `session_task`, `TerminalOutputEvent`, `TerminalExitEvent`, `SessionTitleEvent`, `SessionRemoteControlEvent`. + - Surface IPC (tauri-specta) : `spawn_session`, `send_message` (text + `images: Vec` base64), `answer_permission`, `set_permission_mode`, `interrupt_session`, `stop_session`, `stop_task`, `load_persisted_state`, `upsert_repo`, `delete_repo`, `upsert_conversation`, `delete_conversation`, `set_active_conversation`, `wipe_all_data`, `list_worktrees`, `worktree_status`, `create_worktree`, `remove_worktree`, `read_dir`, `read_file`, `read_image`, `write_file`, `watch_dir`, `unwatch_dir`, `load_subagent_transcript`, `load_workflow_run`, `load_workflow_journal`, `load_workflow_phases`, `read_task_output`, `rewind_conversation`, `fork_conversation`, `request_user_attention(critical:bool)`, `get_plan_usage`, `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `generate_conversation_title`, `set_remote_control`. Events : `session_state`, `session_message`, `session_permission`, `FsChangeEvent`, `FsWatchErrorEvent`, `session_task`, `TerminalOutputEvent`, `TerminalExitEvent`, `SessionTitleEvent`, `SessionRemoteControlEvent`. - `src/` (React) : `features/{flightdeck,conversation,editor,terminal,git,explorer,settings}`, `ipc/`, `store/`, `agent/`, `notifications/`, `ui/` - - `store/` clés : `conversationsStore.ts` (groupement par repo), `backgroundTasksStore.ts` (registre tâches de fond + sélecteurs Bash/Monitor/Workflow, running-first ; `useRunningTaskCount` par conv + `runningCountsByConv`/`useRunningCountsByConv` = counts par conv pour l'agrégat flotte), `workflowLive.ts` (accumulation `task_progress` par phase), `planUsage.ts` (TanStack Query, poll 5 min), `notifications.ts` (3 prefs localStorage), `display.ts` (prefs d'affichage localStorage `tosse:display` : `cleanOutput`, `markdownMode` défaut `warm`, `fleetBannerFlightDeck`/`fleetBannerConversation` ON, `alertOnBackgroundWait` ON, `showTaskNotifications` OFF), `workFold.ts` (état déplié/replié clean-output par conv, localStorage), `remoteControl.ts` (état live-only, keyé convId), `commandsStore.ts` (catalogue slash-commands caché par cwd, localStorage), `updater.ts` (auto-check lancement + 2h ; `inAppReleaseNotes` = notes affichées in-app, split sur ``), `appErrors.ts` (bannière erreurs systémiques déduplicatées). Attachements d'images du composeur : `features/conversation/composerAttachments.ts` (store en mémoire par conv, NON persisté ; nettoyé sur removeConversation/removeRepo/wipeAllData comme composerDrafts). + - `store/` clés : `conversationsStore.ts` (groupement par repo), `backgroundTasksStore.ts` (registre tâches de fond + sélecteurs Bash/Monitor/Workflow, running-first ; `useRunningTaskCount` par conv + `runningCountsByConv`/`useRunningCountsByConv` = counts par conv pour l'agrégat flotte), `workflowLive.ts` (accumulation `task_progress` par phase), `planUsage.ts` (TanStack Query, poll 5 min), `notifications.ts` (3 prefs localStorage), `display.ts` (prefs d'affichage localStorage `tosse:display` : `cleanOutput`, `markdownMode` défaut `warm`, `fleetBannerFlightDeck`/`fleetBannerConversation` ON, `alertOnBackgroundWait` ON, `showTaskNotifications` OFF, `showLastMessagePreview` ON, `messageControls` ON), `workFold.ts` (état déplié/replié clean-output par conv, localStorage), `remoteControl.ts` (état live-only, keyé convId), `commandsStore.ts` (catalogue slash-commands caché par cwd, localStorage), `updater.ts` (auto-check lancement + 2h ; `inAppReleaseNotes` = notes affichées in-app, split sur ``), `appErrors.ts` (bannière erreurs systémiques déduplicatées). Attachements d'images du composeur : `features/conversation/composerAttachments.ts` (store en mémoire par conv, NON persisté ; nettoyé sur removeConversation/removeRepo/wipeAllData comme composerDrafts). - `agent/` : `status.ts` (statut agent — `isActivelyRunning` gate la confirmation de suppression ; `readoutBucket` = les 8 `kind` → 4 stages du fleet readout ; `bg?`/`backgroundCount()` sur review/needInput/error = « fini mais tâche de fond en cours » ; `deriveAgentStatus` prend `alertWhileBackgrounding`), `fleet.ts` (ordonnancement flotte + fleet readout : `tallyFleet`/`useFleetCounts`/`fleetSegments`/`mergedFleetSegments`/`isFleetCalm` — l'agrégat reçoit désormais les tâches de fond par conv via `runningCountsByConv`, corrige un trou où le backgrounding était ignoré), `ask.ts` (classification permission), `subagentMeta.ts` (`isRunInBackground`, `isDetachedAgentAck` (≥2 marqueurs), `runIdFromResult`). - `notifications/` : `notify.ts`, `sound.ts` (carillon Web Audio, zéro asset), `transition.ts` (`agentEventFor` — point UNIQUE de détection des transitions d'état agent). - `src/ui/` : `Toggle.tsx`, `kit.tsx` (`ContextMeter`, `TodoPips`, `ChipBtn`, `Dot` avec prop `ring`), `ConfirmDialog.tsx` (modale oui/non réutilisable, props `danger`/`busy`, portal), `shortcuts.ts` (helpers purs robustesse AZERTY + registre `ACTION_BINDINGS`/`matchChord` + catalogue d'affichage `SHORTCUT_GROUPS`). @@ -160,6 +164,7 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell - Identité de conversation : **id stable** (UUID, PK persistée) distinct du **handle de session live** (`session-N`, en mémoire, non persisté). Le front est keyé par id stable pour toutes les LECTURES ; le handle n'est résolu qu'à l'envoi de commandes. - Spawn **paresseux** : aucun process `claude` au démarrage. Historique lu du transcript on-disk (`loadSessionHistory`). Process spawné à la volée au 1er message (`--resume` si `sessionId`). - Teardown **sans orphelins** : chaque `claude` dans son propre groupe (`process_group(0)`) ; arrêt via `kill(-pid, …)` tout le groupe selon l'échelle EOF → SIGTERM → SIGKILL ; kill-all au quit (attend que le registre `Sessions` se vide, borné). +- **Rewind / Fork d'une conversation** (contrôles au survol des messages, `supervisor/history.rs` : `rewind_transcript` / `fork_transcript` / `resolve_cut`) : le rewind TRONQUE le transcript on-disk (in place, destructif) et le fork le COPIE sous un nouveau `session_id`, toujours à une frontière de **prompt humain** (jamais de `tool_use` orphelin), puis re-spawn `--resume` (VÉRIFIÉ : `--resume` honore un transcript tronqué, aucun cache caché ; il résout le fichier par le slug du cwd). ⚠️ **Invariant** : toute mutation du transcript on-disk exige un arrêt **SYNCHRONE** du process — `SessionHandle::shutdown_and_wait()` (le `Shutdown` porte un `oneshot` déclenché APRÈS `transport.shutdown()`), utilisé par `stop_session`, PAS le simple `shutdown()` qui ne fait qu'enfiler la commande dans le canal — sinon la troncature court contre un writer vivant (tâche de fond, tour remote) → transcript corrompu. ⚠️ Les tours **live** portent un id synthétique (`user_N`, `addUserTurn`) ABSENT du disque → ciblage d'un tour user par le TEXTE via `prompt_match_key` (partagé Rust `history.rs` + TS `ConductorThread.promptMatchKey`, à garder miroir ; tolère slash `/foo` vs header `` et le placeholder `[image]`) + **index d'occurrence** passé par le front pour désambiguïser les prompts identiques répétés (« ok », « continue »). UI : `MessageActions` + gating dans `ConductorThread.tsx` (exclut le 1er prompt et les tours `injectedMidTurn`, masqué en modale de réponse Flight Deck) ; pref `messageControls`. **Worktrees** - Outils natifs `EnterWorktree` / `ExitWorktree` interceptés dans `useGlobalSessionEvents` → rafraîchit la liste UI. Le cwd n'est PAS figé. @@ -185,13 +190,14 @@ Réimplémentation clean-room en Rust du client de l'extension VS Code officiell **Rendu & affichage** - **Surfaçage d'erreur unifié** : toute erreur visible dans la vue conversation (`ConversationItem::Notice` + `addErrorTurn`). Subtypes normalisés : `control_error`, `process_exited`, `send_failed`, `protocol_error`, `permission_error`, `history_error`, `error`. - **Transitions d'état agent** : point UNIQUE dans `useGlobalSessionEvents.ts` via `agentEventFor` (`transition.ts`, fonction pure). Règles : `awaiting_permission` false→true = attention ; `busy` true→false (vivant) = terminé. **Ne PAS dupliquer cette détection**. ⚠️ Le ping OS « terminé » est SUPPRIMÉ quand le statut résultant est `backgrounding` (fini proprement + tâche de fond en cours + réglage `alertOnBackgroundWait` OFF) — gating sur le statut dérivé, pas de branchement ad hoc dans `notifyTransition`. -- **Fleet readout & alerte « terminé + tâche de fond »** : `ui/FleetReadout.tsx` (+ `conductor-fleet.css`) = ligne adaptative des compteurs d'agents par stage (Running · Review · Need Attention · Idle), chiffres colorés, zéros masqués, activité d'abord, « Fleet rests · N Idle » au repos, portée = TOUTE la flotte. Deux placements : bandeau large en haut du Flight Deck (a **REMPLACÉ** l'ancien `AttentionBar`) + encadré compact 1 ligne en bas de la sidebar Conversation (fusionne Review+Need Attention en un seul « Attention » → 3 stages, pour tenir sur une ligne même à la largeur mini 190px). Réglages : 2 toggles séparés (`fleetBanner*`). — Un agent qui finit son tour alors qu'une tâche de fond tourne encore reçoit un **accent violet** sur son review/question (pour le distinguer d'un vrai fini) : anneau violet `.wf-dot..bgring` (box-shadow 2 couches = tint de la couleur + violet ; la SÉPARATION sombre est indispensable, sinon bleu et violet fusionnent) + chip « N en fond » dans la carte Flight Deck (`StateBlock`). Réglage `alertOnBackgroundWait` (défaut ON) : OFF → l'agent passe directement en `backgrounding` (violet calme) sans alerte, le review ne surface qu'une fois la tâche de fond aussi terminée ; une question / une erreur alertent toujours. +- **Fleet readout & alerte « terminé + tâche de fond »** : `ui/FleetReadout.tsx` (+ `conductor-fleet.css`) = ligne adaptative des compteurs d'agents par stage (Running · Review · Need Attention · Idle), chiffres colorés, zéros masqués, activité d'abord, « Fleet rests · N Idle » au repos, portée = TOUTE la flotte. Deux placements : bandeau large en haut du Flight Deck (a **REMPLACÉ** l'ancien `AttentionBar`) + encadré compact 1 ligne en bas de la sidebar Conversation (fusionne Review+Need Attention en un seul « Attention » → 3 stages, pour tenir sur une ligne même à la largeur mini 190px). Réglages : 2 toggles séparés (`fleetBanner*`). — Un agent qui finit son tour alors qu'une tâche de fond tourne encore reçoit un **accent violet** sur son review/question (pour le distinguer d'un vrai fini) : anneau violet `.wf-dot..bgring` (box-shadow 2 couches = tint de la couleur + violet ; la SÉPARATION sombre est indispensable, sinon bleu et violet fusionnent ; spreads ENTIERS 2px+4px — un spread fractionnaire sur le cœur impair de 7px rasterisait l'anneau asymétrique) + chip « N en fond » dans la carte Flight Deck (`StateBlock`). Réglage `alertOnBackgroundWait` (défaut ON) : OFF → l'agent passe directement en `backgrounding` (violet calme) sans alerte, le review ne surface qu'une fois la tâche de fond aussi terminée ; une question / une erreur alertent toujours. - **Mode Clean output** (`cleanOutput`, localStorage `tosse:display`) : repli du travail intermédiaire par round. Invariant liveness via `atomStillRunning` : si un `BackgroundTask` existe pour le tool_use, SON statut fait autorité (`running` → reste visible) ; sinon fallback `!tool_result`. **NE PAS keyer la complétion d'un sous-agent sur le seul `tool_result`** (peut arriver avant le `task_notification` terminal → fold/carte incohérents). Un tour user injecté mid-travail (`InlineUserMarker`) rend aussi ses images jointes. - **Garde ``** : le parser (`parseSpecialMessage`) se déclenche UNIQUEMENT si le texte trimmé **OUVRE** sur `` (anti-faux-positif — un prompt qui mentionne le tag ne doit pas déclencher le rendu). De plus, le rendu (`SpecialMessageCard`) est **MASQUÉ PAR DÉFAUT** (pref `showTaskNotifications` OFF) car ces messages injectés par le CLI polluent le fil au reload / import depuis l'historique. Gate UNIQUE dans `SpecialMessageCard` → couvre les 3 surfaces (`MsgUser`, `InlineUserMarker` clean-output, `SubAgentTranscript`) ; le composant de rendu propre (`TaskNotificationCard`) est CONSERVÉ intact — flip le toggle Réglages pour les réafficher. +- **Pin « dernier message envoyé »** (`LastMessagePin`, monté dans `ConversationPane`) : bandeau flottant épinglé en haut de la vue conversation (`position:absolute` dans `.cv-pane` en `position:relative` ; classe `.cv-lastpin`, pilule translucide `backdrop-filter:blur`), reprenant l'aperçu du Flight Deck — résumé Haiku live (`useLastMessageSummary`) sinon troncature `summaryPreview` du dernier message user (`useUserMessageHistory`, survit au reload). Clic → `scrollIntoView({block:"center"})` sur le dernier `.cv-msg.cv-user`, requête **scopée au `paneRef`** (jusqu'à 3 `ConversationPane` montés à la fois). Réglage `showLastMessagePreview` (défaut ON, Réglages → Général → Affichage). - **Invocation de skill/slash-command** : expansée en messages `role:user` (pas de type dédié). Le body SKILL.md est **droppé** (jamais de bulle user) : **au reload** via `isMeta:true` (`history.rs::push_user`), mais **en live** le CLI **OMET `isMeta`** sur le wire (ligne nue) → drop via le prefix `Base directory for this skill:` tant qu'un `tool_use` Skill est armé (`assembler.rs`, champ `skill_invocation_pending`, désarmé à `ingest_result` ; double garde prefix+flag → jamais de faux-drop d'un vrai tour). Fixtures `capture_skill.jsonl` (disque, avec isMeta) + `capture_skill_live.jsonl` (live, sans isMeta). Un skill **model-invoqué** (`tool_use{name:"Skill"}`) rend un **chip commande dédié** (`SkillChip` + segment `skill` dans `toolGroup.ts`, calqué sur agent/workflow — casse le run, non compté comme travail) ; la voie composer (`parseSlashCommand`, header ``) reste inchangée. Wire : `docs/claude-code-protocol.md` §3.7.1. - **Rendu Markdown** : 3 modes (Classic/Warm/Minimal, défaut `warm`), posé via `data-md-mode` sur `.md-body` → variantes CSS dans `conductor-markdown-modes.css`. Composant réutilisable `StreamMarkdown` (`src/features/conversation/StreamMarkdown.tsx`, react-markdown + remark-gfm) — réutilisé hors conversation (ex. notes de mise à jour dans les Réglages). Coloration syntaxique : `highlight.js` importé lazy, **langages taggés ET connus seulement** (pas de `highlightAuto` — perf + zéro fausse détection). Chip de chemin (`FileMention.tsx`) : rendu segmenté réservé aux VRAIS fichiers (gating `target != null || demo`). - **Bindings IPC** (`src/ipc/bindings.ts`) : générés par tauri-specta → **toujours regénérer et committer avant PR** (le build release ne regénère pas). -- **Flight Deck — reply modal** (`FlightDeckReplyModal` + `flightdeckModalStore`) : répondre à une conversation en modale par-dessus le Flight Deck sans quitter la vue. Réutilise `ConversationPane` verbatim (3ᵉ point de montage, keyé id stable) SANS `SidePanel` (léger, par design). Store keyé id stable, INDÉPENDANT de l'activeId ; modale montée dans `App`, **gated sur la vue flightdeck** + fermée par un effet en quittant la vue → jamais la même conv montée en modale ET plein écran. Clics : TITRE de carte = plein écran, TOUT bouton d'action (« Vu » excepté) = modale, bouton « Plein écran » promeut. `FileMentionProvider` gagne un flag `inert` (propagé via `ConversationPane.inertMentions`) : dans la modale (pas d'éditeur monté) les chemins de fichiers sont du texte simple — sinon clic mort + flip silencieux du flag persisté `editorOpen`. `notify.ts` : la conv ouverte en modale compte comme « regardée » (via `useFlightdeckModal.getState().convId`) → pas de notif OS redondante. +- **Flight Deck — reply modal** (`FlightDeckReplyModal` + `flightdeckModalStore`) : répondre à une conversation en modale par-dessus le Flight Deck sans quitter la vue. Réutilise `ConversationPane` verbatim (3ᵉ point de montage, keyé id stable) SANS `SidePanel` (léger, par design). Store keyé id stable, INDÉPENDANT de l'activeId ; modale montée dans `App`, **gated sur la vue flightdeck** + fermée par un effet en quittant la vue → jamais la même conv montée en modale ET plein écran. Clics : TITRE de carte = plein écran, TOUT bouton d'action (« Vu » excepté) = modale, bouton « Plein écran » promeut. `FileMentionProvider` gagne un flag `inert` (propagé via `ConversationPane.inertMentions`) : dans la modale (pas d'éditeur monté) les chemins de fichiers sont du texte simple — sinon clic mort + flip silencieux du flag persisté `editorOpen`. `ConversationPane.disableMessageControls` masque de même les contrôles rewind/fork au survol (jamais de rembobinage destructif / fork basculant depuis cette surface légère). `notify.ts` : la conv ouverte en modale compte comme « regardée » (via `useFlightdeckModal.getState().convId`) → pas de notif OS redondante. - **Échap : garde plein écran globale + « une touche = une couche »** : une garde en **phase CAPTURE** au niveau `window` (`App.tsx`) fait `preventDefault(Échap)` **INCONDITIONNELLEMENT** (sauf focus dans Monaco `.monaco-editor` / xterm `.xterm`) — c'est la SEULE autorité qui empêche macOS de sortir du **plein écran natif** (comportement par défaut quand un keydown atteint AppKit non consommé). ⚠️ Un `preventDefault` en phase BUBBLE ne suffit PAS à bloquer WKWebView (constaté : Extensions le faisait, ça sortait quand même) — d'où la phase capture. ⚠️ Fix JS **à reconfirmer sur build** ; si un cas sort encore du plein écran → interception côté Rust/Tauri (fenêtre native). Comme cette garde consomme TOUJOURS Échap, on n'utilise PLUS `!e.defaultPrevented` pour éviter la double-fermeture : les popovers drill-in `document` (`TranscriptPopover` / `TaskOutputPopover` / `WorkflowDetail` / badge tâches de fond `BackgroundTaskBadge`) font **`stopPropagation()`** sur leur Échap → le calque `window` externe (reply modal, Extensions) ne reçoit tout simplement pas la touche quand un popover interne la possède → un Échap ne ferme qu'UNE couche. `ExtensionsManager` + `FlightDeckReplyModal` ne gatent donc PLUS sur `defaultPrevented` (ils ferment sur Échap tout court). Les menus capture-phase + `stopPropagation` (slash-menu du composer, ReviewBar) restent inchangés. ## Commandes dev @@ -209,7 +215,7 @@ CI (`.github/workflows/ci.yml`) : vitest + cargo test + build front. Ne tourne q ## Builds de test locaux (dev) -Alexandre **dogfoode** l'app de production (`/Applications/Tosse Code.app`, identifiant `com.tosse.desktop`) — ses vraies conversations y vivent. +Alexandre **dogfoode** l'app de production (`/Applications/Tosse Code.app`, identifiant `com.tosse.desktop`) — ses vraies conversations y vivent. (Nom de fichier `Tosse Code.app` conservé sur les installs existantes malgré le rebrand « Flight Deck » — cf. section « Nom affiché » en tête.) ⚠️ **Piège** : `tauri dev` ET `tauri build` réutilisent le MÊME identifiant → même base SQLite (`~/Library/Application Support/com.tosse.desktop/`). Un build de test lancé tel quel **écrase les données de prod**. diff --git a/package.json b/package.json index 787b878..ed5ed5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tosse-code", - "version": "0.24.0", + "version": "0.25.0", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6ecc685..2059d0f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4713,7 +4713,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tosse-code" -version = "0.24.0" +version = "0.25.0" dependencies = [ "base64 0.22.1", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b6afd86..1c2e364 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tosse-code" -version = "0.24.0" +version = "0.25.0" description = "Flight Deck — desktop app to pilot Claude Code" authors = ["Tosse"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index aa49ab4..4becfb3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Flight Deck", - "version": "0.24.0", + "version": "0.25.0", "identifier": "com.tosse.desktop", "build": { "beforeDevCommand": "pnpm dev", From 4b0ffed01c22231c65228035a5d17af36d29cd24 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Sat, 4 Jul 2026 13:38:09 +0200 Subject: [PATCH 25/91] =?UTF-8?q?feat(brand):=20auto-renomme=20le=20bundle?= =?UTF-8?q?=20=C2=AB=20Tosse=20Code.app=20=C2=BB=20=E2=86=92=20=C2=AB=20Fl?= =?UTF-8?q?ight=20Deck.app=20=C2=BB=20au=20lancement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les installs d'avant le rebrand gardent le nom de fichier « Tosse Code.app » (l'updater remplace en place) → Spotlight/Finder affichent encore « Tosse Code » (kMDItemDisplayName suit kMDItemFSName, PAS CFBundleDisplayName — vérifié). On ne peut pas renommer côté release, donc l'app se répare seule au démarrage : rename → lsregister -f → relaunch. Identifiant com.tosse.desktop INCHANGÉ → données + TCC + DR de signature préservés (ils dépendent de l'identifiant + certificat, jamais du nom de fichier). Fail-safe : relaunch UNIQUEMENT après un rename réussi (aucune boucle) ; ne se déclenche QUE sur le nom exact « Tosse Code.app » → dev et bundles de test (/build-dev, /build-app) épargnés. Décision extraite en fonction pure + 4 tests unitaires. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 138 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6737b81..1bf612f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -257,10 +257,91 @@ fn repair_env_path() { } } +/// The bundle filename legacy installs still carry: they predate the "Flight Deck" +/// rebrand and the auto-updater replaces the bundle IN PLACE, keeping its old name. +#[cfg(target_os = "macos")] +const LEGACY_BUNDLE_NAME: &str = "Tosse Code.app"; +/// The bundle filename matching the current display name ("Flight Deck"). +#[cfg(target_os = "macos")] +const CURRENT_BUNDLE_NAME: &str = "Flight Deck.app"; + +/// Pure decision for the one-time bundle rename: the path the running `.app` should +/// be renamed to, or `None` when no migration is needed. Split from the filesystem +/// work so the guard is unit-tested. +/// +/// Fires ONLY when the bundle is EXACTLY `Tosse Code.app`; after the rename the name +/// no longer matches, so it can never fire twice (no relaunch loop). Dev builds and +/// the `/build-*` test bundles carry other names and are left untouched. +#[cfg(target_os = "macos")] +fn legacy_bundle_rename_target(bundle_path: &std::path::Path) -> Option { + (bundle_path.file_name()?.to_str()? == LEGACY_BUNDLE_NAME) + .then(|| bundle_path.with_file_name(CURRENT_BUNDLE_NAME)) +} + +/// The running `.app` bundle path, derived from the executable +/// (`…/Foo.app/Contents/MacOS/` → `…/Foo.app`). `None` when the layout doesn't +/// match — e.g. `cargo run` / `tauri dev` from `target/`, where there is no bundle. +#[cfg(target_os = "macos")] +fn running_bundle_path() -> Option { + let exe = std::env::current_exe().ok()?; + let bundle = exe.parent()?.parent()?.parent()?; // pop MacOS/, Contents/ → Foo.app + (bundle.extension()?.to_str()? == "app").then(|| bundle.to_path_buf()) +} + +/// One-time self-heal for installs that predate the "Flight Deck" rebrand. +/// +/// The display name is "Flight Deck" (productName), but the auto-updater replaces the +/// bundle IN PLACE, so an install first set up as `Tosse Code.app` keeps that +/// FILENAME forever. macOS derives the Spotlight/Finder name from the filename +/// (verified: `kMDItemDisplayName` tracks `kMDItemFSName`, NOT `CFBundleDisplayName`) +/// → such installs still surface as "Tosse Code" in Spotlight though the Dock/menus +/// read "Flight Deck". We can't rename users' bundles from the release side, so the +/// app renames ITSELF on launch, re-registers with LaunchServices, and relaunches. +/// +/// The IDENTIFIER (`com.tosse.desktop`) is untouched → data dir, TCC grants and the +/// signing DR are all preserved (they key on identifier + certificate, never the +/// filename). Fail-safe: any failure (not a bundle, unwritable `/Applications`, +/// target already present, rename error) leaves the app running normally under its +/// old name — we relaunch ONLY after a successful rename, so a failure never loops. +#[cfg(target_os = "macos")] +fn migrate_legacy_bundle_name() { + let Some(bundle) = running_bundle_path() else { + return; + }; + let Some(target) = legacy_bundle_rename_target(&bundle) else { + return; + }; + if target.exists() { + return; // never clobber a `Flight Deck.app` already sitting alongside. + } + if std::fs::rename(&bundle, &target).is_err() { + return; // unwritable (admin-owned /Applications, read-only DMG) → keep old name. + } + + // Refresh LaunchServices so Spotlight/Finder pick up the new filename immediately. + let _ = std::process::Command::new( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", + ) + .arg("-f") + .arg(&target) + .status(); + + // Hand off to the renamed bundle (the Dock tile / aliases still point at the old + // path until the process restarts) and exit this instance. + let _ = std::process::Command::new("/usr/bin/open").arg(&target).spawn(); + std::process::exit(0); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Restore the user's PATH before anything can spawn a child process (the lazy - // `claude` session, any tool it runs). Must run first — see fn doc. + // Legacy installs first set up as `Tosse Code.app` rename themselves to + // `Flight Deck.app` and relaunch — before any other startup work, so the hand-off + // is immediate. No-op on fresh installs and in dev (not a `.app` bundle). + #[cfg(target_os = "macos")] + migrate_legacy_bundle_name(); + + // Restore the user's PATH before we spawn any child process (the lazy `claude` + // session, any tool it runs). See fn doc. #[cfg(target_os = "macos")] repair_env_path(); @@ -467,3 +548,56 @@ mod path_repair_tests { assert_eq!(extract_sentinel_path("/usr/bin:/bin no markers here"), None); } } + +#[cfg(all(test, target_os = "macos"))] +mod bundle_migration_tests { + use super::*; + use std::path::{Path, PathBuf}; + + #[test] + fn renames_the_exact_legacy_bundle() { + assert_eq!( + legacy_bundle_rename_target(Path::new("/Applications/Tosse Code.app")), + Some(PathBuf::from("/Applications/Flight Deck.app")), + ); + } + + #[test] + fn renames_in_place_wherever_the_bundle_lives() { + // Self-heal works from any location the user put it (as long as it's writable + // at runtime) — only the filename is swapped, the parent dir is preserved. + assert_eq!( + legacy_bundle_rename_target(Path::new("/Users/me/Desktop/Tosse Code.app")), + Some(PathBuf::from("/Users/me/Desktop/Flight Deck.app")), + ); + } + + #[test] + fn already_renamed_is_a_noop() { + // After the rename the name no longer matches → the migration never fires + // twice, so a successful rename can't produce a relaunch loop. + assert_eq!( + legacy_bundle_rename_target(Path::new("/Applications/Flight Deck.app")), + None, + ); + } + + #[test] + fn leaves_dev_and_test_bundles_alone() { + // Only the EXACT legacy name migrates; everything else is left untouched. + for name in [ + "Flight Deck dev build.app", // /build-dev + "FlightDeck feat-x.app", // /build-app + "Tosse Code Test State.app", // test-state overlay + "Tosse Code.app.bak", // not exactly the legacy name + "Tosse Code", // no .app at all + ] { + let p = PathBuf::from("/Applications").join(name); + assert_eq!( + legacy_bundle_rename_target(&p), + None, + "should not migrate {name}", + ); + } + } +} From 56b325aeb5f7dfd0156290cdd5308feb7659b1a2 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Tue, 7 Jul 2026 01:17:28 +0200 Subject: [PATCH 26/91] =?UTF-8?q?fix(notif):=20recr=C3=A9e=20un=20AudioCon?= =?UTF-8?q?text=20frais=20=C3=A0=20chaque=20son=20(fiabilit=C3=A9=20WKWebV?= =?UTF-8?q?iew)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le carillon cessait souvent de fonctionner après qu'une autre source audio (vidéo, autre app) ait reconfiguré l'unité audio macOS : l'AudioContext partagé, gardé en vie toute la session, devenait muet en silence (son `state` restait "running" mais plus aucun son ne sortait, et resume() ne le réparait pas). Aucun signal JS ne permet de détecter « running mais muet ». Fix : ne plus jamais garder un AudioContext en vie. Chaque playChime construit un contexte frais (lié à la config audio courante), joue, puis le close(). Le bouton « Tester le son » crée son contexte dans le geste de clic → toujours audible. Plafond de 4 contextes vivants (limite WebKit), close() tolérant à un retour undefined (ancien WebKit), dégradation gracieuse inchangée. Co-Authored-By: Claude Opus 4.8 --- src/notifications/sound.test.ts | 56 ++++++++++-- src/notifications/sound.ts | 148 ++++++++++++++++++++------------ 2 files changed, 143 insertions(+), 61 deletions(-) diff --git a/src/notifications/sound.test.ts b/src/notifications/sound.test.ts index 51a3b29..94cbb8a 100644 --- a/src/notifications/sound.test.ts +++ b/src/notifications/sound.test.ts @@ -28,10 +28,14 @@ describe("sound", () => { }); }); -// The reliability fix: a SUSPENDED context (WKWebView backgrounded / pre-gesture) -// must be resume()d BEFORE the notes are scheduled, otherwise they're silently -// dropped. A minimal fake AudioContext lets us assert the ordering under jsdom. -describe("sound — suspended AudioContext is resumed before scheduling", () => { +// A minimal fake AudioContext so we can assert the reliability behaviours under +// jsdom (which has no Web Audio). Two properties matter for the fix: +// 1. a SUSPENDED context is resume()d BEFORE the notes are scheduled, otherwise +// they're silently dropped; +// 2. every play builds a FRESH context and close()s it once decayed, so a stale +// "running but silent" context from an earlier audio-session change (the user +// playing a video) can never carry over. +describe("sound — fresh-context-per-play reliability", () => { const audioParam = () => ({ value: 0, setValueAtTime: vi.fn(), @@ -42,7 +46,8 @@ describe("sound — suspended AudioContext is resumed before scheduling", () => /** A fake context whose resume() flips `state` to "running" ASYNCHRONOUSLY (on a * microtask), like the real one — so scheduling before the await would see it - * still suspended, which is exactly the bug we guard against. */ + * still suspended, which is exactly the bug we guard against. close() flips it + * to "closed" so the module's live-context bookkeeping stays honest. */ function makeFakeAudio(initial: "suspended" | "running") { const oscillators: Array<{ start: ReturnType }> = []; const ctx = { @@ -53,6 +58,10 @@ describe("sound — suspended AudioContext is resumed before scheduling", () => this.state = "running"; }); }), + close: vi.fn(function (this: { state: string }) { + this.state = "closed"; + return Promise.resolve(); + }), createGain: () => ({ gain: audioParam(), ...node() }), createBiquadFilter: () => ({ type: "", frequency: { value: 0 }, Q: { value: 0 }, ...node() }), createOscillator: () => { @@ -67,6 +76,7 @@ describe("sound — suspended AudioContext is resumed before scheduling", () => afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); delete (window as unknown as { AudioContext?: unknown }).AudioContext; }); @@ -103,4 +113,40 @@ describe("sound — suspended AudioContext is resumed before scheduling", () => expect(oscillators.length).toBeGreaterThan(0); for (const osc of oscillators) expect(osc.start).toHaveBeenCalled(); }); + + it("builds a FRESH AudioContext for every play (never reuses a stale one)", async () => { + vi.resetModules(); + const made: Array["ctx"]> = []; + (window as unknown as { AudioContext: unknown }).AudioContext = function () { + const { ctx } = makeFakeAudio("running"); + made.push(ctx); + return ctx; + }; + const { playChime } = await import("./sound"); + + playChime("done"); + playChime("attention"); + playChime("done"); + // One brand-new context per play — this is what defeats the "running but + // silent" stale-context bug after other audio has reconfigured the device. + expect(made).toHaveLength(3); + }); + + it("closes the context once the chime has decayed (single-use lifecycle)", async () => { + vi.resetModules(); + const { ctx } = makeFakeAudio("running"); + (window as unknown as { AudioContext: unknown }).AudioContext = function () { + return ctx; + }; + const { playChime } = await import("./sound"); + + vi.useFakeTimers(); + playChime("done"); + // Still ringing out — the context must stay open until the tail has decayed. + expect(ctx.close).not.toHaveBeenCalled(); + // Fast-forward well past the longest chime (~1.6s) + teardown margin. + vi.advanceTimersByTime(5000); + expect(ctx.close).toHaveBeenCalled(); + expect(ctx.state).toBe("closed"); + }); }); diff --git a/src/notifications/sound.ts b/src/notifications/sound.ts index 73d0408..09e50d2 100644 --- a/src/notifications/sound.ts +++ b/src/notifications/sound.ts @@ -8,30 +8,62 @@ // // Kept deliberately tiny and self-contained; this fits the project's "fast and // very optimized" principle better than shipping and decoding an audio file. +// +// ── Reliability: a FRESH AudioContext per play ────────────────────────────── +// WKWebView (Tauri's macOS webview) shares the system audio unit. When another +// app or an HTML5