From 9c966031f30692937efd0a98d81eb16c90e04341 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Wed, 16 Sep 2026 21:17:31 +0200 Subject: [PATCH 1/5] fix(artifacts): recognize the claude 2.1.272 artifact URL shape claude 2.1.272 moved hosted artifacts from https://claude.ai/code/artifact/ to https://claude.ai/artifact/. The URL regex only matched the old shape, so every inline card read "Unavailable" and prose artifact links rendered as bare anchors. Accept both shapes (older transcripts keep the old URL), and share one isArtifactPublish predicate between the inline card and the chip so the new asset uploads (asset:true + file_path) never render as an artifact. Co-Authored-By: Claude Opus 5 --- src/features/conversation/ArtifactRefCard.tsx | 2 +- .../conversation/artifactOpen.test.ts | 5 +++ src/features/conversation/artifacts.test.ts | 19 +++++++++ src/features/conversation/artifacts.ts | 40 +++++++++++++------ src/features/conversation/toolGroup.test.ts | 7 ++++ src/features/conversation/toolGroup.ts | 12 +++--- 6 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/features/conversation/ArtifactRefCard.tsx b/src/features/conversation/ArtifactRefCard.tsx index 53222bb1..7e958b79 100644 --- a/src/features/conversation/ArtifactRefCard.tsx +++ b/src/features/conversation/ArtifactRefCard.tsx @@ -1,5 +1,5 @@ // A compact, pretty clickable card for an artifact LINK that Claude writes in its prose -// (`[label](https://claude.ai/code/artifact/)`) — rendered in place of a plain anchor by +// (`[label](https://claude.ai/artifact/)`, or the pre-2.1.272 `…/code/artifact/`) — rendered in place of a plain anchor by // StreamMarkdown's link renderer. When the artifact belongs to this conversation it is enriched // from the registry (favicon + title) and opens in the in-app viewer; otherwise it opens the // hosted page in the browser. Inline-block so it flows inside a paragraph. diff --git a/src/features/conversation/artifactOpen.test.ts b/src/features/conversation/artifactOpen.test.ts index 54ca924e..8a461a87 100644 --- a/src/features/conversation/artifactOpen.test.ts +++ b/src/features/conversation/artifactOpen.test.ts @@ -11,8 +11,13 @@ describe("isArtifactUrl", () => { expect(isArtifactUrl("https://claude.ai/code/artifact/acecfb35-f63b-49c3-b835-d0c856695a94")).toBe(true); }); + it("matches the 2.1.272+ shape (claude.ai/artifact/) — else a prose link renders as a bare anchor", () => { + expect(isArtifactUrl("https://claude.ai/artifact/66XHYkMzjJ4BdfJ64qa3cy")).toBe(true); + }); + it("rejects other claude.ai URLs and non-artifact links", () => { expect(isArtifactUrl("https://claude.ai/code/artifacts")).toBe(false); // the gallery, not one artifact + expect(isArtifactUrl("https://claude.ai/artifacts")).toBe(false); expect(isArtifactUrl("https://claude.ai/code/session_01ABC")).toBe(false); expect(isArtifactUrl("https://example.com/x")).toBe(false); expect(isArtifactUrl("/abs/path.html")).toBe(false); diff --git a/src/features/conversation/artifacts.test.ts b/src/features/conversation/artifacts.test.ts index 871f2cb9..2f21c4f8 100644 --- a/src/features/conversation/artifacts.test.ts +++ b/src/features/conversation/artifacts.test.ts @@ -61,6 +61,17 @@ describe("artifactUrlFromResult", () => { it("handles the array content shape ({text})", () => { expect(artifactUrlFromResult([{ type: "text", text: SHORT("/tmp/x.html", URL_B) }] as unknown as JsonValue)).toBe(URL_B); }); + it("parses the 2.1.272+ URL shape (claude.ai/artifact/, no /code/)", () => { + // Verbatim first lines of a real 2.1.272 ack: new host path + "(Version N)" suffix + a + // "Stored — contract …" paragraph. Missing this shape turned every card "Unavailable". + const url = "https://claude.ai/artifact/66XHYkMzjJ4BdfJ64qa3cy"; + const ack = `Published /private/tmp/claude-501/x/scratchpad/catalogue/index.html at ${url} (Version 1)\n\nStored — contract 0.2.52 · capabilities db · sharing owner.`; + expect(artifactUrlFromResult(ack)).toBe(url); + }); + it("does not mistake the artifacts GALLERY for an artifact", () => { + expect(artifactUrlFromResult("See https://claude.ai/code/artifacts")).toBeNull(); + expect(artifactUrlFromResult("See https://claude.ai/artifacts")).toBeNull(); + }); it("returns null on empty / missing / non-canonical text (degrade, no dead link)", () => { expect(artifactUrlFromResult(undefined)).toBeNull(); expect(artifactUrlFromResult("")).toBeNull(); @@ -133,6 +144,14 @@ describe("selectArtifacts", () => { expect(selectArtifacts(e)).toEqual([]); }); + it("skips asset uploads (asset:true carries a file_path but is not a page of its own)", () => { + const e = entryOf( + [{ id: "t1", blocks: [tuse("a1", { url: URL_A, file_path: "/tmp/logo.png", asset: true })] }], + { a1: "Uploaded" }, + ); + expect(selectArtifacts(e)).toEqual([]); + }); + it("ignores non-Artifact tool_uses", () => { const e = entryOf([{ id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html" }, "Write")] }]); expect(selectArtifacts(e)).toEqual([]); diff --git a/src/features/conversation/artifacts.ts b/src/features/conversation/artifacts.ts index 6c699dac..4fff6aea 100644 --- a/src/features/conversation/artifacts.ts +++ b/src/features/conversation/artifacts.ts @@ -1,5 +1,5 @@ // Front-derived registry of the artifacts Claude published in a conversation via the -// `Artifact` tool (a hosted HTML/MD page at claude.ai/code/artifact/). +// `Artifact` tool (a hosted HTML/MD page at claude.ai/artifact/). // // Everything is DERIVED from the message stream already in `conversationStore` — the // `Artifact` tool_use inputs (file_path / description / favicon / label) joined to their @@ -18,10 +18,27 @@ import { resultText } from "../../agent/subagentMeta"; import { basename } from "./toolMeta"; /** The canonical hosted-artifact URL shape. The publish tool_result is free text that - * ALWAYS begins "Published at https://claude.ai/code/artifact/"; we anchor - * on this shape rather than parsing the surrounding human prose (which drifts across CLI - * versions — short vs long "To update:" forms). */ -export const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/code\/artifact\/[A-Za-z0-9-]+/; + * ALWAYS begins "Published at "; we anchor on the URL shape rather than + * parsing the surrounding human prose (which drifts across CLI versions — short vs long + * "To update:" forms, the "(Version N)" suffix). + * + * ⚠️ BOTH shapes, on purpose. Up to claude 2.1.270 the URL was + * `https://claude.ai/code/artifact/`; from 2.1.272 it is `https://claude.ai/artifact/` + * (a ~22-char base58 id). Transcripts written by older binaries keep the old URL forever, so + * dropping it would regress every conversation already on disk. Missing the new one is what + * turned every card "Unavailable" and every prose link into a bare anchor. The trailing `/` + * keeps the gallery (`…/code/artifacts`) out. */ +export const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9-]+/; + +/** True when an `Artifact` tool_use is a real PUBLISH of a local page — the only call that is a + * deliverable. The tool also does `action:"list"`/`"read"`/`"quickstart"` and bare `url`-updates + * (no `file_path`), and `asset:true` UPLOADS (a `file_path` that is an image/font/PDF pushed into + * an existing artifact's asset store — not a page of its own). SINGLE source of truth for the + * inline card (`groupBlocks`) and the chip's list (`selectArtifacts`), so they never disagree. */ +export function isArtifactPublish(input: JsonValue): boolean { + if (!field(input, "file_path")) return false; + return (input as Record).asset !== true; +} /** Pull the published URL out of an `Artifact` tool_result. Null while the publish is still * in flight (no result yet) or if the ack is ever reworded past the canonical URL shape — @@ -52,7 +69,7 @@ export interface ArtifactVersion { /** An artifact grouped across its versions for one conversation. */ export interface Artifact { - /** Hosted URL (claude.ai/code/artifact/). Null only in the brief window between a + /** Hosted URL (see {@link ARTIFACT_URL_RE}). Null only in the brief window between a * publish tool_use and its tool_result landing. */ url: string | null; /** Emoji favicon — of the most recent version that set it (last-known-good, so a republish @@ -98,9 +115,9 @@ function artifactTitle(latestLabel: string | null, latestFilePath: string): stri * maps to the same URL, but the URL is only known once the tool_result lands — file_path is * known at tool_use time, so it is the stable provisional key that never splits a republish * into two items. Labels repeat across different files, so they are never a key. - * - Tool_uses with no file_path (an `action:"list"` or a bare cross-conversation url-update) are - * skipped — they don't describe a local publish. (The inline card path mirrors this guard in - * `groupBlocks`.) + * - Anything but a real publish ({@link isArtifactPublish}: list/read/quickstart, a bare + * cross-conversation url-update, an asset upload) is skipped — it doesn't describe a local page. + * (The inline card path uses the same predicate in `groupBlocks`.) * - An artifact whose EVERY publish terminally FAILED (all versions `is_error`, no URL) is dropped * from this list: it is not an openable artifact, so it must not inflate the "Artifacts (N)" chip * nor sit there mislabelled as "not published yet". The failure is still surfaced in the thread @@ -116,9 +133,8 @@ export function selectArtifacts(entry: SessionEntry | undefined): Artifact[] { const turn = entry.turns[t.id]; if (!turn || turn.role !== "assistant" || turn.parentToolUseId !== null) continue; for (const b of turn.blocks) { - if (b.type !== "tool_use" || b.name !== "Artifact") continue; - const filePath = field(b.input, "file_path"); - if (!filePath) continue; + if (b.type !== "tool_use" || b.name !== "Artifact" || !isArtifactPublish(b.input)) continue; + const filePath = field(b.input, "file_path")!; const label = field(b.input, "label") ?? null; const description = field(b.input, "description") ?? null; const favicon = field(b.input, "favicon") ?? null; diff --git a/src/features/conversation/toolGroup.test.ts b/src/features/conversation/toolGroup.test.ts index 3408a0e9..7abc0f1f 100644 --- a/src/features/conversation/toolGroup.test.ts +++ b/src/features/conversation/toolGroup.test.ts @@ -234,6 +234,13 @@ describe("groupBlocks — Artifact (publish only)", () => { const urlUpdate = groupBlocks([tool("u", "Artifact", { url: "https://claude.ai/code/artifact/abc" })]); expect(urlUpdate.map((s) => s.kind)).toEqual(["run"]); }); + + it("does NOT treat an asset upload (asset:true + file_path) as an artifact card", () => { + const upload = groupBlocks([ + tool("u", "Artifact", { url: "https://claude.ai/artifact/abc", file_path: "/tmp/logo.png", asset: true }), + ]); + expect(upload.map((s) => s.kind)).toEqual(["run"]); + }); }); describe("groupBlocks — in-band markers (mid-turn separator)", () => { diff --git a/src/features/conversation/toolGroup.ts b/src/features/conversation/toolGroup.ts index 3c23228c..e29aa395 100644 --- a/src/features/conversation/toolGroup.ts +++ b/src/features/conversation/toolGroup.ts @@ -16,6 +16,7 @@ import { parseMcpToolName, prettyMcpServer } from "../../agent/toolNames"; import { basename, toolMeta } from "./toolMeta"; import { diffCounts, lineDiff } from "./lineDiff"; import { isAgentMessagingTool } from "./agentMessage"; +import { isArtifactPublish } from "./artifacts"; /** Lucide-ish icon token per tool, resolved by the UI's . Shared so the live * step rows and the static transcript pick the same glyph for a given tool. */ @@ -200,12 +201,11 @@ export function groupBlocks( // An Artifact PUBLISH is its own inline card (a deliverable link) — breaks the run so the // published page stands out instead of hiding in a "Ran N steps" step row (and its .html // file_path never renders as an editor-opening chip pointing at a disposable temp file). - // Only a real publish qualifies: the `Artifact` tool also does `action:"list"` (enumerate - // the user's artifacts) and bare cross-conversation `url`-updates, which carry NO file_path - // and are NOT a local deliverable — those fall through to the normal run/step path so their - // result is shown like any other tool. This MUST mirror `selectArtifacts` (the chip's - // derivation, which skips file_path-less calls) or the two surfaces disagree. - if (b.name === "Artifact" && field(b.input, "file_path")) { + // Only a real publish qualifies: the `Artifact` tool also lists/reads, does bare + // cross-conversation `url`-updates and uploads assets, none of which is a local deliverable + // — those fall through to the normal run/step path so their result is shown like any other + // tool. The predicate is shared with `selectArtifacts` so the two surfaces never disagree. + if (b.name === "Artifact" && isArtifactPublish(b.input)) { run = null; out.push({ kind: "artifact", key: `art-${i}`, step: { id: b.id, name: b.name, input: b.input } }); return; From 102fd8c0f9841b982c3f90af60f925f8b1d0d079 Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Wed, 16 Sep 2026 21:19:32 +0200 Subject: [PATCH 2/5] chore(release): v2.3.1 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3235e65..5548dae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ that section and uses it as the GitHub release description, which the app displa as-is. The install instructions block (after the `` marker) is added by `release.yml` and stays **only** on the GitHub page — it does not appear in the app. +## v2.3.1 + +- Fix: **artifacts published by Claude open again** — inline artifact cards no longer show "Unavailable", and artifact links in Claude's replies are back to clickable cards (Claude Code 2.1.272 changed the artifact link format). + ## v2.3.0 - New: **messages between conversations are readable** — the receiving conversation shows which conversation sent a message, and the sender shows a compact "Message to …" line with its delivery status. diff --git a/package.json b/package.json index 7decf63e..c7270997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tosse-code", - "version": "2.3.0", + "version": "2.3.1", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 42eeb153..44eb18c1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5233,7 +5233,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tosse-code" -version = "2.3.0" +version = "2.3.1" dependencies = [ "base64 0.22.1", "cpal", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 783d3423..85c835a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tosse-code" -version = "2.3.0" +version = "2.3.1" 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 41924cb1..648697a9 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": "2.3.0", + "version": "2.3.1", "identifier": "com.tosse.desktop", "build": { "beforeDevCommand": "pnpm dev", From 575a3128721e810c2eb893f32ff62d20af6e13a0 Mon Sep 17 00:00:00 2001 From: armand_mounsi Date: Thu, 17 Sep 2026 12:56:04 +0200 Subject: [PATCH 3/5] refactor(settings): shorten the rail, move Claude-only prefs under Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings rail had grown to 13 top-level tabs, several of them a single page, and one of them ("Behavior") holding nothing but Claude-only prefs while sitting outside the Claude Code tab. Reorganise around one rule: a tab is a top-level CONCERN, sub-tabs carry the rest. The rail is now 9 tabs: - General (Accounts · System) — who the agents sign in as, and what the app may do to this machine. Absorbs the old Accounts tab; keeps the About block. - Display (Appearance · Thread · Durations · Composer · Models · Order) — a new tab for everything the app SHOWS. Absorbs the old General → Display sub-tab, the whole Conversation tab (Markdown folded into Thread), the Durations sub-tab, and the top-level Reordering tab. - Claude Code (Instructions · Behavior · Helpers) — gains sub-tabs, and takes over the old top-level "Behavior" tab. Output style writes `~/.claude/settings.json`, and the bypass unlock arms a `claude` CLI flag: both are Claude-only and do nothing for a Codex conversation, so they now follow this tab's `needsClaude` visibility. Accepted consequence: with no Claude account signed in, neither is reachable. Routing and spend stay on ONE page — the spend table is the bill for the routing above it. Plumbing that had to follow: - `openSettings` takes an optional sub-tab, so the auth warning bar still deep-links to General → Accounts instead of dropping the user on the machine card. Passing no sub leaves the remembered sub-tabs alone. - The 37 moved entries of the settings search index are remapped; the unit test that catches a result pointing at a sub-tab nobody renders stays green. - `DisplayPrefs` split into `AppearancePrefs` / `ThreadPrefs` / `MotionPrefs`, and `ClaudeCodeSection` now exports two groups instead of a page — so `SettingsPanel` remains the single place describing the panel's shape (same pattern as `ControlSection`). No pref, toggle or default changes behaviour: this moves where settings live, not what they do. Co-Authored-By: Claude Opus 5 --- src/features/conversation/AuthWarningBar.tsx | 4 +- src/features/settings/AccountsSection.tsx | 8 +- src/features/settings/SettingsPanel.tsx | 230 +++++++++++------- .../settings/claudecode/ClaudeCodeSection.tsx | 27 +- src/features/settings/settingsSearch.ts | 91 +++---- src/store/settingsUi.test.ts | 26 +- src/store/settingsUi.ts | 42 ++-- 7 files changed, 265 insertions(+), 163 deletions(-) diff --git a/src/features/conversation/AuthWarningBar.tsx b/src/features/conversation/AuthWarningBar.tsx index 8308fcba..c0e5a6e1 100644 --- a/src/features/conversation/AuthWarningBar.tsx +++ b/src/features/conversation/AuthWarningBar.tsx @@ -42,7 +42,7 @@ export function AuthWarningBar({ session }: { session: string }) { {name} CLI not found — the next messages will fail. - @@ -58,7 +58,7 @@ export function AuthWarningBar({ session }: { session: string }) { {name} account not connected — the next messages will fail. - diff --git a/src/features/settings/AccountsSection.tsx b/src/features/settings/AccountsSection.tsx index 3af912fa..eac468f8 100644 --- a/src/features/settings/AccountsSection.tsx +++ b/src/features/settings/AccountsSection.tsx @@ -44,7 +44,9 @@ const DEFAULT_ACCOUNT_KEY = DEFAULT_ACCOUNT_ID; /** Usage at or above this reads as "nearly full" — the same line the context ring draws. */ const WARN_PERCENT = 80; -export function AccountsSection() { +// `embedded` = rendered inside the General tab's "Accounts" sub-tab, which already +// carries the page heading — so the section drops its own. +export function AccountsSection({ embedded = false }: { embedded?: boolean }) { // Tri-state (null while the one-shot probe is in flight): show the "CLI not found" tile // ONLY on a DEFINITIVE `false`. While still checking, render the normal tiles — they have // their own "Checking…" state — so a user with the CLI never sees a false alarm flash. @@ -52,7 +54,9 @@ export function AccountsSection() { const codex = useBackendAvailabilityState("codex"); return (
- + {!embedded && ( + + )}
{claude === false ? : } {codex === false ? : } diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index 3a006b68..039ff0a0 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -1,8 +1,11 @@ -// Settings modal — a left-rail tabbed panel (built to scale as more settings -// land). Sections: General (about), Notifications, Updates, Data (the -// destructive "drop all", kept while the SQL model is still in flux). The active -// section is shared state so deep-links (e.g. the update banner) can open it -// straight onto a given tab. +// Settings modal — a left-rail tabbed panel. The active section is shared state so +// deep-links (e.g. the update banner, the auth warning bar) can open it straight onto a +// given tab and sub-tab. +// +// The rail is deliberately SHORT: a tab is a top-level CONCERN, not a single page. +// Everything the app shows lives under "Display"; everything Claude-specific under +// "Claude Code"; who you are signed in as and what the app may do to this machine under +// "General". Sub-tabs carry the rest — growing the rail is the wrong axis. import { useEffect, useMemo, useState, type ReactNode } from "react"; import { getVersion } from "@tauri-apps/api/app"; import { demoteBypassConversations, wipeAllData } from "../../store/conversationsStore"; @@ -18,7 +21,7 @@ import { ClaudeCliSection } from "./ClaudeCliSection"; import { NotificationsSection } from "./NotificationsSection"; import { ConversationSection } from "./ConversationSection"; import { ModelsSection } from "./ModelsSection"; -import { ClaudeCodeSection } from "./claudecode/ClaudeCodeSection"; +import { ClaudeCodeHelpers, ClaudeCodeInstructions } from "./claudecode/ClaudeCodeSection"; import { useClaudeAccount } from "../../ipc/useAccounts"; import { AccountsSection } from "./AccountsSection"; import { TosseSection } from "./TosseSection"; @@ -46,27 +49,27 @@ const TABS: Array<{ /** Hidden until a Claude account is connected. */ needsClaude?: boolean; }> = [ + // You and this machine: the accounts the agents sign in with, and the anti-sleep hold. { id: "general", label: "General", icon: "cog" }, - { id: "accounts", label: "Accounts", icon: "key" }, - // TOSSE sits next to Accounts (both are "connect to a service") but stays its own tab: - // Accounts signs the AGENTS in to their model providers, this signs YOU in to the CRM. + // TOSSE sits next to the accounts (both are "connect to a service") but stays its own + // tab: General → Accounts signs the AGENTS in to their model providers, this signs YOU + // in to the CRM. { id: "tosse", label: "TOSSE", icon: "list", mark: }, - // Everything that shapes a conversation — Markdown rendering, the model picker, and the - // composer bar — behind this tab's sub-tabs (was three separate top-level tabs). - { id: "conversation", label: "Conversation", icon: "chat" }, - // How CLAUDE itself behaves (not how we render it): its output style, what it is allowed - // to do without asking. Next to Conversation — both shape what a conversation is. - { id: "behavior", label: "Behavior", icon: "bot" }, + // Everything the app SHOWS, behind this tab's sub-tabs: the app's look and motion, the + // thread (Markdown included), timings, the composer bar, the model picker, and the + // manual order of conversations and cards. Was a General sub-tab plus two top-level + // tabs of its own (Conversation, Reordering). + { id: "display", label: "Display", icon: "sidebar" }, // Backend-specific by design, and only present while that backend is CONNECTED: the - // page is Claude model names and Claude file layout end to end, so an abstraction over - // both backends would have to speak in euphemisms. A Codex twin would be its own tab. - { id: "claudeCode", label: "Claude Code", icon: "bot", needsClaude: true }, - { id: "reordering", label: "Reordering", icon: "reorder" }, + // page is Claude model names, Claude file layout and Claude-only CLI flags end to end, + // so an abstraction over both backends would have to speak in euphemisms. A Codex twin + // would be its own tab. Holds what used to be the top-level "Behavior" tab (output + // style, bypass permissions) — both are Claude-only and do nothing for Codex. + { id: "claudeCode", label: "Claude Code", icon: "code", needsClaude: true }, { id: "shortcuts", label: "Shortcuts", icon: "key" }, // Agents piloting the app: the in-process MCP server, the voice agent, the bridge. { id: "control", label: "Control", icon: "wand" }, - // OS channels + the fleet readout + background-task alerts, behind this tab's sub-tabs - // (the last two moved out of the old General → Alerts). + // OS channels + the fleet readout + background-task alerts, behind this tab's sub-tabs. { id: "notifications", label: "Notifications", icon: "bell" }, { id: "updates", label: "Updates", icon: "refresh" }, { id: "data", label: "Data", icon: "trash" }, @@ -76,15 +79,29 @@ const TABS: Array<{ * ids are mirrored by `settingsSearch.SETTINGS_SUBS` (a unit test keeps the search index * from pointing at a sub-tab that doesn't exist). */ const GENERAL_SUBS = [ - { id: "display", label: "Display", icon: "list" }, - { id: "timing", label: "Durations", icon: "clock" }, + { id: "accounts", label: "Accounts", icon: "users" }, { id: "system", label: "System", icon: "cog" }, ] as const; -const CONVERSATION_SUBS = [ - { id: "markdown", label: "Markdown", icon: "chat" }, - { id: "models", label: "Models", icon: "spark" }, +/** One tab for everything the app shows, split by the SURFACE each pref acts on rather + * than by the store it lives in — so "where do I turn that off?" has one answer per + * surface: the app itself (Appearance), the conversation (Thread / Durations), the + * composer bar, the model picker, and the order things are listed in. */ +const DISPLAY_SUBS = [ + { id: "appearance", label: "Appearance", icon: "eye" }, + { id: "thread", label: "Thread", icon: "chat" }, + { id: "timing", label: "Durations", icon: "clock" }, { id: "composer", label: "Composer", icon: "wand" }, + { id: "models", label: "Models", icon: "spark" }, + { id: "order", label: "Order", icon: "reorder" }, +] as const; + +/** Claude Code, widest scope first: the instructions every conversation starts from, then + * how Claude itself behaves, then the helpers it delegates to and what they cost. */ +const CLAUDE_CODE_SUBS = [ + { id: "instructions", label: "Instructions", icon: "file" }, + { id: "behavior", label: "Behavior", icon: "shield" }, + { id: "helpers", label: "Helpers", icon: "bot" }, ] as const; const CONTROL_SUBS = [ @@ -101,7 +118,8 @@ const NOTIFICATIONS_SUBS = [ ] as const; type GeneralSub = (typeof GENERAL_SUBS)[number]["id"]; -type ConversationSub = (typeof CONVERSATION_SUBS)[number]["id"]; +type DisplaySub = (typeof DISPLAY_SUBS)[number]["id"]; +type ClaudeCodeSub = (typeof CLAUDE_CODE_SUBS)[number]["id"]; type ControlSub = (typeof CONTROL_SUBS)[number]["id"]; type NotificationsSub = (typeof NOTIFICATIONS_SUBS)[number]["id"]; @@ -196,8 +214,9 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => const subs = useSettingsUi((s) => s.subs); const setSub = useSettingsUi((s) => s.setSub); const revealSetting = useSettingsUi((s) => s.revealSetting); - const generalSub = (subs.general ?? "display") as GeneralSub; - const conversationSub = (subs.conversation ?? "markdown") as ConversationSub; + const generalSub = (subs.general ?? "accounts") as GeneralSub; + const displaySub = (subs.display ?? "appearance") as DisplaySub; + const claudeCodeSub = (subs.claudeCode ?? "instructions") as ClaudeCodeSub; const controlSub = (subs.control ?? "agents") as ControlSub; const notificationsSub = (subs.notifications ?? "channels") as NotificationsSub; const [query, setQuery] = useState(""); @@ -321,7 +340,10 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => {!searching && section === "general" && (
- +
@@ -342,60 +364,75 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => onSelect={(id) => setSub("general", id)} ariaLabel="General settings" /> - {generalSub === "display" && } - {generalSub === "timing" && } - {/* "Bypass permissions" now lives in the Behavior tab — it is about what - Claude may do, not about this machine. System keeps the machine card. */} + {/* Accounts was a top-level tab; it is the first thing this one is about. */} + {generalSub === "accounts" && } + {/* "Bypass permissions" is NOT here — it is a Claude-only CLI flag and + lives in Claude Code → Behavior. System is the machine card. */} {generalSub === "system" && }
)} - {!searching && section === "accounts" && } - {!searching && section === "tosse" && } - {!searching && section === "conversation" && ( -
+ {!searching && section === "display" && ( +
- {/* One sub-page at a time. Markdown rendering, the model picker and the - composer bar were three separate top-level tabs — bundled here because - each shapes what a conversation is; the drag surfaces (model lists, - composer bar) keep their own sub-page. */} + {/* One sub-page at a time. Appearance and Motion share a page (both are + "how the app itself looks"); Markdown sits with the rest of the thread + rather than on a page of its own. */} setSub("conversation", id)} - ariaLabel="Conversation settings" + tabs={DISPLAY_SUBS} + active={displaySub} + onSelect={(id) => setSub("display", id)} + ariaLabel="Display settings" /> - {conversationSub === "markdown" && } - {conversationSub === "models" && } - {conversationSub === "composer" && } + {displaySub === "appearance" && ( + <> + + + + )} + {displaySub === "thread" && ( + <> + + + + )} + {displaySub === "timing" && } + {displaySub === "composer" && } + {displaySub === "models" && } + {displaySub === "order" && }
)} - {!searching && section === "behavior" && ( + {!searching && section === "claudeCode" && (
- - -
- )} - - {!searching && section === "claudeCode" && } - - {!searching && section === "reordering" && ( -
- setSub("claudeCode", id)} + ariaLabel="Claude Code settings" /> - + {claudeCodeSub === "instructions" && } + {claudeCodeSub === "behavior" && ( + <> + + + + )} + {claudeCodeSub === "helpers" && }
)} @@ -521,26 +558,13 @@ const MINIMAP_HOVER_MODES: Array<{ id: MinimapHoverMode; label: string; desc: st }, ]; -/** The "Display" sub-tab of the General tab, split into three cards so a wall of a dozen - * unrelated switches reads as three intents: Appearance (the app's global look and what - * the Flight Deck card shows), Thread (how the conversation itself reads), and Motion (the - * optional animations, each of which the system's "reduce motion" always overrides). Every - * toggle here is a GLOBAL default — e.g. "clean output" folds each round's work behind a - * "Work" block, and a conversation's composer chip can still override its own. */ -function DisplayPrefs() { +/** The "Appearance" card of Display → Appearance: the app's global look and what the + * Flight Deck card shows. Shares its sub-page with {@link MotionPrefs} — both answer + * "how does the app itself look", as opposed to the thread ({@link ThreadPrefs}). */ +function AppearancePrefs() { const uiZoom = useDisplay((s) => s.uiZoom); - const cleanOutput = useDisplay((s) => s.cleanOutput); - const showTaskNotifications = useDisplay((s) => s.showTaskNotifications); - const showLastMessagePreview = useDisplay((s) => s.showLastMessagePreview); - const messageMinimap = useDisplay((s) => s.messageMinimap); - const minimapHoverMode = useDisplay((s) => s.minimapHoverMode); const workflowLiveCard = useDisplay((s) => s.workflowLiveCard); const workflowAgentDetail = useDisplay((s) => s.workflowAgentDetail); - const flightdeckModalZoom = useDisplay((s) => s.flightdeckModalZoom); - const panelAnimations = useDisplay((s) => s.panelAnimations); - const conversationAnimations = useDisplay((s) => s.conversationAnimations); - const messageControls = useDisplay((s) => s.messageControls); - const clickableFileMentions = useDisplay((s) => s.clickableFileMentions); const set = useDisplay((s) => s.set); return ( <> @@ -592,6 +616,25 @@ function DisplayPrefs() { /> + + ); +} + +/** The "Thread" card of Display → Thread: how the conversation itself reads. Every toggle + * here is a GLOBAL default — e.g. "clean output" folds each round's work behind a "Work" + * block, and a conversation's composer chip can still override its own. Rendered above + * the Markdown card (`ConversationSection`), which is the same subject. */ +function ThreadPrefs() { + const cleanOutput = useDisplay((s) => s.cleanOutput); + const showTaskNotifications = useDisplay((s) => s.showTaskNotifications); + const showLastMessagePreview = useDisplay((s) => s.showLastMessagePreview); + const messageMinimap = useDisplay((s) => s.messageMinimap); + const minimapHoverMode = useDisplay((s) => s.minimapHoverMode); + const messageControls = useDisplay((s) => s.messageControls); + const clickableFileMentions = useDisplay((s) => s.clickableFileMentions); + const set = useDisplay((s) => s.set); + return ( + <> + + ); +} + +/** The "Motion" card of Display → Appearance: the optional animations, each of which the + * system's "reduce motion" always overrides. */ +function MotionPrefs() { + const flightdeckModalZoom = useDisplay((s) => s.flightdeckModalZoom); + const panelAnimations = useDisplay((s) => s.panelAnimations); + const conversationAnimations = useDisplay((s) => s.conversationAnimations); + const set = useDisplay((s) => s.set); + return ( + <> (p: Promise>): Promise { return r.data; } -export function ClaudeCodeSection() { +/** The "Helpers" sub-page of the Claude Code tab: which model each helper runs on, and + * what those helpers have cost. Routing and spend stay on ONE page on purpose — the + * spend table is the bill for the routing above it, and splitting them would make you + * switch pages to answer "is this routing worth it?". + * + * Exported as a group (not a page) so `SettingsPanel` stays the single place that + * describes the panel's shape — same pattern as `ControlSection`'s groups. */ +export function ClaudeCodeHelpers() { const convId = useActiveConversationId(); const repo = useConversationRepo(convId); const repoPath = repo?.path ?? null; - return ( -
- + <> - -
+ ); } +/** The "Instructions" sub-page of the Claude Code tab: the CLAUDE.md files every + * conversation starts from. First sub-tab of that tab — the widest-scope setting there. */ +export function ClaudeCodeInstructions() { + return ; +} + // ---- A. Routing ------------------------------------------------------------ const routingKey = (path: string | null) => ["subagent-routing", path] as const; diff --git a/src/features/settings/settingsSearch.ts b/src/features/settings/settingsSearch.ts index 4f446651..8d86b5b2 100644 --- a/src/features/settings/settingsSearch.ts +++ b/src/features/settings/settingsSearch.ts @@ -34,69 +34,72 @@ export interface SettingEntry { /** The sub-tabs of the sections that split their cards (kept here so the search * and the panel agree on the ids — the test cross-checks the index against it). */ export const SETTINGS_SUBS: Partial> = { - general: ["display", "timing", "system"], - conversation: ["markdown", "models", "composer"], + general: ["accounts", "system"], + display: ["appearance", "thread", "timing", "composer", "models", "order"], + claudeCode: ["instructions", "behavior", "helpers"], control: ["agents", "voice", "remote", "bridge"], notifications: ["channels", "fleet", "background"], }; export const SETTINGS_INDEX: readonly SettingEntry[] = [ - // ---- General ------------------------------------------------------------ - { title: "Interface zoom", section: "general", sub: "display", group: "Appearance", keywords: "scale text size bigger smaller zoom police taille display affichage" }, - { title: "Clean output (default)", section: "general", sub: "display", group: "Thread", keywords: "fold work block hide tools sortie propre repli display affichage" }, - { title: "Background task notifications", section: "general", sub: "display", group: "Thread", keywords: "task-notification messages thread display affichage" }, - { title: "Preview of the last sent message", section: "general", sub: "display", group: "Thread", keywords: "pin last message apercu dernier message display affichage" }, - { title: "Message minimap", section: "general", sub: "display", group: "Thread", keywords: "scrollbar map jump navigation display affichage" }, - { title: "Live workflow on the Flight Deck card", section: "general", sub: "display", group: "Appearance", keywords: "workflow card phases display affichage" }, - { title: "Per-agent detail in the workflow view", section: "general", sub: "display", group: "Appearance", keywords: "workflow agents phase live detail agents display affichage" }, - { title: "Zoom when opening a card", section: "general", sub: "display", group: "Motion", keywords: "animation motion modal flight deck display affichage mouvement" }, - { title: "Slide side panels open", section: "general", sub: "display", group: "Motion", keywords: "animation motion panel editor terminal display affichage mouvement" }, - { title: "Animate the conversation", section: "general", sub: "display", group: "Motion", keywords: "animation motion thread display affichage mouvement" }, - { title: "Message controls", section: "general", sub: "display", group: "Thread", keywords: "rewind fork hover controls rembobiner display affichage" }, - { title: "Clickable filename on Read/Write rows", section: "general", sub: "display", group: "Thread", keywords: "file mention path link chemin cliquable display affichage" }, - { title: "Turn duration", section: "general", sub: "timing", group: "Durations & timing", keywords: "time elapsed seconds duree tour" }, - { title: "Model time", section: "general", sub: "timing", group: "Durations & timing", keywords: "api duration breakdown" }, - { title: "Thinking time", section: "general", sub: "timing", group: "Durations & timing", keywords: "reasoning reflexion duration" }, - { title: "Tool time", section: "general", sub: "timing", group: "Durations & timing", keywords: "bash read edit duration outils" }, - { title: "Allow Bypass permissions mode", section: "behavior", group: "Permissions", keywords: "dangerously skip permissions bypass yolo" }, - { title: "Output style", section: "behavior", group: "Output style", keywords: "writing tone concise explanatory style sortie ton" }, + // ---- Display (Appearance / Thread / Durations / Composer / Models / Order) ---- + { title: "Interface zoom", section: "display", sub: "appearance", group: "Appearance", keywords: "scale text size bigger smaller zoom police taille display affichage" }, + { title: "Clean output (default)", section: "display", sub: "thread", group: "Thread", keywords: "fold work block hide tools sortie propre repli display affichage" }, + { title: "Background task notifications", section: "display", sub: "thread", group: "Thread", keywords: "task-notification messages thread display affichage" }, + { title: "Preview of the last sent message", section: "display", sub: "thread", group: "Thread", keywords: "pin last message apercu dernier message display affichage" }, + { title: "Message minimap", section: "display", sub: "thread", group: "Thread", keywords: "scrollbar map jump navigation display affichage" }, + { title: "Live workflow on the Flight Deck card", section: "display", sub: "appearance", group: "Appearance", keywords: "workflow card phases display affichage" }, + { title: "Per-agent detail in the workflow view", section: "display", sub: "appearance", group: "Appearance", keywords: "workflow agents phase live detail agents display affichage" }, + { title: "Zoom when opening a card", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion modal flight deck display affichage mouvement" }, + { title: "Slide side panels open", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion panel editor terminal display affichage mouvement" }, + { title: "Animate the conversation", section: "display", sub: "appearance", group: "Motion", keywords: "animation motion thread display affichage mouvement" }, + { title: "Message controls", section: "display", sub: "thread", group: "Thread", keywords: "rewind fork hover controls rembobiner display affichage" }, + { title: "Clickable filename on Read/Write rows", section: "display", sub: "thread", group: "Thread", keywords: "file mention path link chemin cliquable display affichage" }, + { title: "Turn duration", section: "display", sub: "timing", group: "Durations & timing", keywords: "time elapsed seconds duree tour" }, + { title: "Model time", section: "display", sub: "timing", group: "Durations & timing", keywords: "api duration breakdown" }, + { title: "Thinking time", section: "display", sub: "timing", group: "Durations & timing", keywords: "reasoning reflexion duration" }, + { title: "Tool time", section: "display", sub: "timing", group: "Durations & timing", keywords: "bash read edit duration outils" }, + { title: "Allow Bypass permissions mode", section: "claudeCode", sub: "behavior", group: "Permissions", keywords: "dangerously skip permissions bypass yolo" }, + { title: "Output style", section: "claudeCode", sub: "behavior", group: "Output style", keywords: "writing tone concise explanatory style sortie ton" }, { title: "Keep the Mac awake", section: "general", sub: "system", group: "Caffeinate", keywords: "sleep veille caffeinate energy" }, - // ---- Accounts / TOSSE --------------------------------------------------- - { title: "Accounts", section: "accounts", group: "Accounts", keywords: "claude codex openai login sign in connexion compte" }, + // ---- General (Accounts / System) + TOSSE -------------------------------- + { title: "Accounts", section: "general", sub: "accounts", group: "Accounts", keywords: "claude codex openai login sign in connexion compte" }, // Multiple Claude accounts: a grid of account tiles (each with its own rate-limit rings), // then the Switching group. Tiles are not rows, so the tile entries only land on the right // tab; the threshold steppers are labels inside the Switching card, so they flash the card. - { title: "Claude accounts", section: "accounts", group: "Claude accounts", keywords: "multiple accounts several rate limits usage quota 5h 7d weekly rings plusieurs comptes limites utilisation forfait" }, - { title: "Add another Claude account", section: "accounts", group: "Claude accounts", keywords: "new second account sign in ajouter compte" }, - { title: "Switching", section: "accounts", group: "Switching", keywords: "switch accounts default auto bascule changement compte" }, - { title: "Default account for new conversations", section: "accounts", group: "Switching", keywords: "default account compte par defaut nouvelle conversation" }, - { title: "Auto-switch account near usage limit", section: "accounts", group: "Switching", keywords: "auto switch rotate failover quota limit bascule automatique changement compte limite" }, - { title: "Switch at", flash: "Switching", section: "accounts", group: "Switching", keywords: "threshold trigger percent seuil declenchement pourcentage auto-switch" }, - { title: "Target below", flash: "Switching", section: "accounts", group: "Switching", keywords: "ceiling hysteresis target oscillation plafond cible auto-switch" }, + { title: "Claude accounts", section: "general", sub: "accounts", group: "Claude accounts", keywords: "multiple accounts several rate limits usage quota 5h 7d weekly rings plusieurs comptes limites utilisation forfait" }, + { title: "Add another Claude account", section: "general", sub: "accounts", group: "Claude accounts", keywords: "new second account sign in ajouter compte" }, + { title: "Switching", section: "general", sub: "accounts", group: "Switching", keywords: "switch accounts default auto bascule changement compte" }, + { title: "Default account for new conversations", section: "general", sub: "accounts", group: "Switching", keywords: "default account compte par defaut nouvelle conversation" }, + { title: "Auto-switch account near usage limit", section: "general", sub: "accounts", group: "Switching", keywords: "auto switch rotate failover quota limit bascule automatique changement compte limite" }, + { title: "Switch at", flash: "Switching", section: "general", sub: "accounts", group: "Switching", keywords: "threshold trigger percent seuil declenchement pourcentage auto-switch" }, + { title: "Target below", flash: "Switching", section: "general", sub: "accounts", group: "Switching", keywords: "ceiling hysteresis target oscillation plafond cible auto-switch" }, { title: "TOSSE mark on repositories", section: "tosse", group: "In the app", keywords: "badge repo crm" }, { title: "TOSSE tasks view", section: "tosse", group: "In the app", keywords: "tasks board kanban taches" }, { title: "Stay on the tasks view when you press Start", section: "tosse", group: "In the app", keywords: "pickup start navigation" }, { title: "Warn before deleting a linked conversation", section: "tosse", group: "In the app", keywords: "delete confirm task suppression" }, { title: "Client logos from the web", section: "tosse", group: "In the app", keywords: "favicon google privacy logo client" }, - // ---- Claude Code (its own tab, only while a Claude account is connected) --- - { title: "Helpers", section: "claudeCode", group: "Helpers", keywords: "subagent routing model helper sous-agent routage claude code" }, - { title: "What the helpers cost", section: "claudeCode", group: "What the helpers cost", keywords: "spend cost tokens dashboard depense cout" }, - { title: "Instructions for Claude", section: "claudeCode", group: "Instructions for Claude", keywords: "claude md instructions file memoire" }, + // ---- Claude Code (Instructions / Behavior / Helpers) -------------------- + // Its own tab, shown only while a Claude account is connected — so are the two + // Behavior entries above, which is why they are Claude-only settings. + { title: "Helpers", section: "claudeCode", sub: "helpers", group: "Helpers", keywords: "subagent routing model helper sous-agent routage claude code" }, + { title: "What the helpers cost", section: "claudeCode", sub: "helpers", group: "What the helpers cost", keywords: "spend cost tokens dashboard depense cout" }, + { title: "Instructions for Claude", section: "claudeCode", sub: "instructions", group: "Instructions for Claude", keywords: "claude md instructions file memoire" }, - // ---- Conversation (Markdown / Models / Composer sub-tabs) --------------- - { title: "Markdown rendering", section: "conversation", sub: "markdown", group: "Markdown", keywords: "markdown mode warm classic minimal thread rendering conversation rendu" }, - { title: "Shown in the picker", section: "conversation", sub: "models", group: "The picker", keywords: "model list claude codex modele" }, - { title: "New conversations", section: "conversation", sub: "models", group: "New conversations", keywords: "default model effort defaut" }, - { title: "The bar", section: "conversation", sub: "composer", group: "The bar", keywords: "composer controls layout barre" }, - { title: "Your buttons", section: "conversation", sub: "composer", group: "Your buttons", keywords: "custom button prompt bouton" }, + // ---- Display, continued: the conversation surfaces ---------------------- + { title: "Markdown rendering", section: "display", sub: "thread", group: "Markdown", keywords: "markdown mode warm classic minimal thread rendering conversation rendu" }, + { title: "Shown in the picker", section: "display", sub: "models", group: "The picker", keywords: "model list claude codex modele" }, + { title: "New conversations", section: "display", sub: "models", group: "New conversations", keywords: "default model effort defaut" }, + { title: "The bar", section: "display", sub: "composer", group: "The bar", keywords: "composer controls layout barre" }, + { title: "Your buttons", section: "display", sub: "composer", group: "Your buttons", keywords: "custom button prompt bouton" }, - // ---- Shortcuts / Reordering --------------------------------------------- + // ---- Shortcuts + Display → Order ---------------------------------------- { title: "Keyboard shortcuts", section: "shortcuts", group: "Keyboard shortcuts", keywords: "keys chords raccourcis clavier" }, - { title: "Conversations", section: "reordering", group: "Conversation order", keywords: "drag drop manual order tri ordre" }, - { title: "Repositories", section: "reordering", group: "Conversation order", keywords: "drag drop manual order depots" }, - { title: "Share order between the two views", section: "reordering", group: "Shared order", keywords: "sidebar flight deck sync" }, + { title: "Conversations", section: "display", sub: "order", group: "Conversation order", keywords: "drag drop manual order tri ordre" }, + { title: "Repositories", section: "display", sub: "order", group: "Conversation order", keywords: "drag drop manual order depots" }, + { title: "Share order between the two views", section: "display", sub: "order", group: "Shared order", keywords: "sidebar flight deck sync" }, // ---- Control ------------------------------------------------------------ { title: "Let agents pilot the app", section: "control", sub: "agents", group: "Agent control of the app", keywords: "mcp flightdeck server in-process tools" }, diff --git a/src/store/settingsUi.test.ts b/src/store/settingsUi.test.ts index 4d355289..5abbfc19 100644 --- a/src/store/settingsUi.test.ts +++ b/src/store/settingsUi.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { useSettingsUi } from "./settingsUi"; -beforeEach(() => useSettingsUi.setState({ open: false, section: "general" })); +beforeEach(() => useSettingsUi.setState({ open: false, section: "general", subs: {} })); describe("settingsUi store", () => { it("opens without changing the current section", () => { @@ -17,6 +17,22 @@ describe("settingsUi store", () => { expect(st.section).toBe("updates"); }); + // The auth warning bar deep-links to General → Accounts, which is a SUB-tab: opening + // on the section alone would drop the user on the machine card instead. + it("opens directly on a section AND one of its sub-tabs", () => { + useSettingsUi.getState().openSettings("general", "accounts"); + const st = useSettingsUi.getState(); + expect(st.open).toBe(true); + expect(st.section).toBe("general"); + expect(st.subs.general).toBe("accounts"); + }); + + it("leaves the remembered sub-tabs alone when no sub is named", () => { + useSettingsUi.getState().setSub("display", "order"); + useSettingsUi.getState().openSettings("display"); + expect(useSettingsUi.getState().subs.display).toBe("order"); + }); + it("remembers the section across close then reopen", () => { useSettingsUi.getState().openSettings("notifications"); useSettingsUi.getState().closeSettings(); @@ -36,7 +52,9 @@ describe("settingsUi store", () => { it("disarms a search highlight that no row claimed", () => { vi.useFakeTimers(); try { - useSettingsUi.getState().revealSetting({ section: "accounts", title: "Claude accounts" }); + useSettingsUi + .getState() + .revealSetting({ section: "general", sub: "accounts", title: "Claude accounts" }); expect(useSettingsUi.getState().highlight).toBe("Claude accounts"); vi.advanceTimersByTime(3000); expect(useSettingsUi.getState().highlight).toBeNull(); @@ -48,7 +66,9 @@ describe("settingsUi store", () => { it("does not let an old reveal's timer clear a newer highlight", () => { vi.useFakeTimers(); try { - useSettingsUi.getState().revealSetting({ section: "accounts", title: "Switching" }); + useSettingsUi + .getState() + .revealSetting({ section: "general", sub: "accounts", title: "Switching" }); vi.advanceTimersByTime(2000); useSettingsUi.getState().revealSetting({ section: "data", title: "Data" }); vi.advanceTimersByTime(1000); diff --git a/src/store/settingsUi.ts b/src/store/settingsUi.ts index edcb4ebd..4468caf0 100644 --- a/src/store/settingsUi.ts +++ b/src/store/settingsUi.ts @@ -6,22 +6,25 @@ import { create } from "zustand"; /** The settings sections, mirrored by the panel's left-rail tabs. */ export type SettingsSection = + /** You and this machine: the accounts your agents sign in with, and the anti-sleep hold. */ | "general" - | "accounts" | "tosse" - // "conversation" now bundles the Markdown mode, the model picker and the composer bar - // behind its own sub-tabs (was three separate top-level tabs: conversation/models/composer). - | "conversation" - | "behavior" - /** Claude-specific settings: sub-agent routing, spend, and the instructions file. - * Backend-specific ON PURPOSE — its whole content is Claude model names and Claude - * file layout, so it is shown only while a Claude account is connected, and a Codex - * twin would be its own tab rather than an abstraction over both. */ + /** Everything about what the app SHOWS and how it reads: the app's look and motion, the + * thread (including Markdown), timings, the composer bar, the model picker, and the + * manual ordering of conversations/cards. Was a sub-tab of General plus three separate + * top-level tabs (conversation, reordering). */ + | "display" + /** Claude-specific settings: the instructions file, how Claude behaves (output style, + * what it may do without asking), and sub-agent routing + spend. + * Backend-specific ON PURPOSE — its whole content is Claude model names, Claude file + * layout and Claude-only CLI flags, so it is shown only while a Claude account is + * connected, and a Codex twin would be its own tab rather than an abstraction over both. + * ⚠️ Consequence, accepted: with no Claude account signed in, "Output style" and + * "Allow Bypass permissions mode" are not reachable — neither does anything to Codex. */ | "claudeCode" - | "reordering" | "shortcuts" | "control" - // "notifications" now bundles the OS channels, the fleet readout and the background-task + // "notifications" bundles the OS channels, the fleet readout and the background-task // alert behind its own sub-tabs (the last two moved out of the old General → Alerts). | "notifications" | "updates" @@ -47,8 +50,8 @@ interface SettingsUiState { * that no longer exists can never leave a highlight armed forever. */ highlight: string | null; - /** Open the panel, optionally jumping straight to `section`. */ - openSettings: (section?: SettingsSection) => void; + /** Open the panel, optionally jumping straight to `section` (and one of its sub-tabs). */ + openSettings: (section?: SettingsSection, sub?: string) => void; closeSettings: () => void; /** Switch the active section while the panel is open. */ setSection: (section: SettingsSection) => void; @@ -71,8 +74,17 @@ export const useSettingsUi = create((set, get) => ({ section: "general", subs: {}, highlight: null, - openSettings: (section) => - set(section ? { open: true, section, highlight: null } : { open: true }), + openSettings: (section, sub) => + set((s) => + section + ? { + open: true, + section, + subs: sub ? { ...s.subs, [section]: sub } : s.subs, + highlight: null, + } + : { open: true }, + ), closeSettings: () => set({ open: false, highlight: null }), setSection: (section) => set({ section, highlight: null }), setSub: (section, sub) => From 135d17d99e60842e6c59eadb849d409ac2c89895 Mon Sep 17 00:00:00 2001 From: armand_mounsi Date: Fri, 18 Sep 2026 09:39:02 +0200 Subject: [PATCH 4/5] fix(caffeinate): hold a 60 s grace before Light mode releases the Mac MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a background task finished, its terminal task_notification dropped the fleet's activity count to zero, but the follow-up turn the CLI starts on its own only reads as busy at its first message_start (seconds later on a large context). Light mode released caffeinate in that gap, and a Mac idle past its sleep timer went to sleep within ~5 s — mid-turn (seen 2026-09-18: slept 1 s before the re-hold). The release that follows a hold now waits LIGHT_RELEASE_GRACE_MS (60 s) and is cancelled if activity comes back. Turning Caffeinate off still releases at once. The set_awake driving moves to a testable useAwakeAssertion hook. Co-Authored-By: Claude Opus 5 --- src/features/power/CaffeinateHost.tsx | 59 ++-------- src/features/power/useAwakeAssertion.test.ts | 113 +++++++++++++++++++ src/features/power/useAwakeAssertion.ts | 79 +++++++++++++ src/features/settings/SettingsPanel.tsx | 2 +- src/store/caffeinate.test.ts | 17 ++- src/store/caffeinate.ts | 22 +++- 6 files changed, 237 insertions(+), 55 deletions(-) create mode 100644 src/features/power/useAwakeAssertion.test.ts create mode 100644 src/features/power/useAwakeAssertion.ts diff --git a/src/features/power/CaffeinateHost.tsx b/src/features/power/CaffeinateHost.tsx index 81573038..1c9eeefb 100644 --- a/src/features/power/CaffeinateHost.tsx +++ b/src/features/power/CaffeinateHost.tsx @@ -1,32 +1,14 @@ -import { useEffect } from "react"; -import { commands } from "../../ipc/client"; import { useFleetCounts } from "../../agent/fleet"; import { useRunningCountsByConv } from "../../store/backgroundTasksStore"; -import { useAppErrors } from "../../store/appErrors"; -import { caffeineDesired, useCaffeinate } from "../../store/caffeinate"; - -/** Slow heartbeat re-asserting the keep-awake hold: if the `caffeinate` child is killed out - * from under us while it should stay held, the next tick calls `set_awake(true)` again and the - * idempotent Rust `hold()` respawns it. Cheap (a no-op while the child is still alive). */ -const REASSERT_MS = 30_000; - -// Serialize every `set_awake` IPC so the calls apply in ISSUE ORDER and the last intent wins. -// The heartbeat above can have an in-flight `setAwake(true)` that — without this — could reach -// the Rust mutex AFTER a near-simultaneous release's `setAwake(false)` (Tauri does not guarantee -// cross-invoke ordering) and strand the Mac held awake, with no further heartbeat to self-correct -// while `desired` is false. Chaining makes a later-issued release always win. Same "serialize the -// writes to a shared resource" discipline as the CLI-config writers. -let awakeChain: Promise = Promise.resolve(); -function setAwakeSerialized(desired: boolean) { - const call = awakeChain.then(() => commands.setAwake(desired)); - awakeChain = call.catch(() => {}); // keep the chain alive past a rejection - return call; -} +import { caffeineDesired, releaseGraceMs, useCaffeinate } from "../../store/caffeinate"; +import { useAwakeAssertion } from "./useAwakeAssertion"; /** * The Caffeinate POLICY, mounted once globally (render-null). Watches the on/off toggle, * the Light/Hard mode and live fleet activity, computes whether the Mac should be held - * awake right now, and pushes that boolean to the Rust `power` service via `set_awake`. + * awake right now, and pushes that boolean to the Rust `power` service via + * {@link useAwakeAssertion} — which, in Light mode, holds a little past the moment the fleet + * goes idle ({@link releaseGraceMs}) to bridge the gap before a follow-up turn reads as busy. * * Activity source for Light mode = "is ANY agent working". `useFleetCounts().running` folds * a running turn and the `backgrounding` state, but it does NOT count a conversation whose @@ -36,10 +18,8 @@ function setAwakeSerialized(desired: boolean) { * mode would let the Mac sleep and stall a background sub-agent — exactly what the feature * exists to prevent. * - * If holding the assertion fails (a `caffeinate` spawn failure), we surface it via the app - * error banner instead of letting the toggle read "on" while the Mac quietly sleeps — the - * "zero silent error" rule. Its own component (not folded into App) so this subscription - * re-renders in isolation on every fleet tick. + * Its own component (not folded into App) so this subscription re-renders in isolation on + * every fleet tick. */ export function CaffeinateHost() { const enabled = useCaffeinate((s) => s.enabled); @@ -48,30 +28,7 @@ export function CaffeinateHost() { const anyAgentActive = useFleetCounts().running > 0 || Object.values(anyBackgroundTask).some((n) => n > 0); - const desired = caffeineDesired(enabled, mode, anyAgentActive); - - useEffect(() => { - const push = async () => { - const res = await setAwakeSerialized(desired); - // Only a hold (desired === true) can fail; a release never does. Surface it so the - // user knows the Mac may sleep despite the toggle showing "on". Deduped by message. - if (desired && res.status === "error") { - useAppErrors - .getState() - .pushError("Couldn't keep the Mac awake — it may go to sleep.", res.error); - } - }; - void push(); - // While the assertion is meant to be HELD, re-assert it on a slow heartbeat. This effect - // only re-runs when `desired` flips, so if the `caffeinate` child dies out from under us - // while `desired` stays true (killall, an OS reap under pressure) nothing else calls - // set_awake again — the Rust-side liveness prune + respawn in `hold()` only runs when - // invoked. A cheap idempotent re-assert (a no-op while the child is alive) closes that - // self-heal gap. Not needed while releasing (desired === false). - if (!desired) return; - const id = setInterval(() => void push(), REASSERT_MS); - return () => clearInterval(id); - }, [desired]); + useAwakeAssertion(caffeineDesired(enabled, mode, anyAgentActive), releaseGraceMs(enabled, mode)); return null; } diff --git a/src/features/power/useAwakeAssertion.test.ts b/src/features/power/useAwakeAssertion.test.ts new file mode 100644 index 00000000..8829910a --- /dev/null +++ b/src/features/power/useAwakeAssertion.test.ts @@ -0,0 +1,113 @@ +// Timing tests for the keep-awake driver, rendered through react-dom/client with fake timers. +// Built with createElement so the file stays a `*.test.ts` (the vitest glob), no JSX. +// +// What is locked here is the 2026-09-18 regression: in Light mode the release must NOT go out +// the instant activity drops — a follow-up turn only reads as busy seconds later, and a Mac +// idle past its sleep timer sleeps within ~5 s of the release. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +const setAwake = vi.fn(async (_awake: boolean) => ({ status: "ok" as const, data: null })); +vi.mock("../../ipc/client", () => ({ commands: { setAwake: (a: boolean) => setAwake(a) } })); + +import { useAwakeAssertion } from "./useAwakeAssertion"; + +const GRACE = 60_000; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + setAwake.mockClear(); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +function Host({ desired, grace }: { desired: boolean; grace: number }) { + useAwakeAssertion(desired, grace); + return null; +} + +/** Render, then let the serialized IPC chain (promise microtasks) settle. */ +async function render(desired: boolean, grace = GRACE) { + await act(async () => { + root.render(createElement(Host, { desired, grace })); + await vi.advanceTimersByTimeAsync(0); + }); +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +/** The `set_awake` intents pushed so far, in order. */ +const intents = () => setAwake.mock.calls.map(([a]) => a); + +describe("useAwakeAssertion", () => { + it("holds, then releases only once the grace window has passed", async () => { + await render(true); + expect(intents()).toEqual([true]); + + await render(false); + expect(intents()).toEqual([true]); // activity dropped: still held + + await advance(GRACE - 1); + expect(intents()).toEqual([true]); + + await advance(1); + expect(intents()).toEqual([true, false]); + }); + + it("never releases when activity comes back within the window", async () => { + await render(true); + await render(false); + await advance(6_000); // the follow-up turn goes busy a few seconds later + await render(true); + + await advance(GRACE * 3); + expect(intents()).not.toContain(false); + }); + + it("releases at once when there is no grace (Caffeinate turned off)", async () => { + await render(true, 0); + await render(false, 0); + expect(intents()).toEqual([true, false]); + }); + + it("releases at once when the grace is dropped mid-window", async () => { + await render(true); + await render(false); + await advance(10_000); + expect(intents()).toEqual([true]); + + await render(false, 0); // the user turns Caffeinate off during the linger + expect(intents()).toEqual([true, false]); + + await advance(GRACE); + expect(intents()).toEqual([true, false]); // the cancelled timer does not fire again + }); + + it("releases at once on mount when nothing was ever held", async () => { + await render(false); + expect(intents()).toEqual([false]); + }); + + it("re-asserts the hold on a heartbeat while desired", async () => { + await render(true); + await advance(30_000); + expect(intents()).toEqual([true, true]); + }); +}); diff --git a/src/features/power/useAwakeAssertion.ts b/src/features/power/useAwakeAssertion.ts new file mode 100644 index 00000000..d33fcaf4 --- /dev/null +++ b/src/features/power/useAwakeAssertion.ts @@ -0,0 +1,79 @@ +// Drives the Rust `power` service's keep-awake assertion from a desired boolean. Split out of +// CaffeinateHost so the timing (serialized IPC, re-assert heartbeat, release grace) can be +// tested without mounting the fleet stores that compute `desired`. +import { useEffect, useRef } from "react"; +import { commands } from "../../ipc/client"; +import { useAppErrors } from "../../store/appErrors"; + +/** Slow heartbeat re-asserting the keep-awake hold: if the `caffeinate` child is killed out + * from under us while it should stay held, the next tick calls `set_awake(true)` again and the + * idempotent Rust `hold()` respawns it. Cheap (a no-op while the child is still alive). */ +const REASSERT_MS = 30_000; + +// Serialize every `set_awake` IPC so the calls apply in ISSUE ORDER and the last intent wins. +// The heartbeat above can have an in-flight `setAwake(true)` that — without this — could reach +// the Rust mutex AFTER a near-simultaneous release's `setAwake(false)` (Tauri does not guarantee +// cross-invoke ordering) and strand the Mac held awake, with no further heartbeat to self-correct +// while `desired` is false. Chaining makes a later-issued release always win. Same "serialize the +// writes to a shared resource" discipline as the CLI-config writers. +let awakeChain: Promise = Promise.resolve(); +function setAwakeSerialized(desired: boolean) { + const call = awakeChain.then(() => commands.setAwake(desired)); + awakeChain = call.catch(() => {}); // keep the chain alive past a rejection + return call; +} + +/** + * Hold the Mac awake while `desired`, release it otherwise — but a release that follows a hold + * waits `releaseGraceMs` first, and is cancelled if `desired` comes back within that window (the + * `caffeinate` child is never killed, so no respawn either). `0` releases at once. The grace is + * read when the release starts: dropping it to `0` mid-window releases immediately. + * + * WebKit may throttle the grace timer while the window is hidden; that only lengthens the + * window, which is the safe direction. + * + * If holding the assertion fails (a `caffeinate` spawn failure), it is surfaced via the app + * error banner instead of letting the toggle read "on" while the Mac quietly sleeps — the + * "zero silent error" rule. + */ +export function useAwakeAssertion(desired: boolean, releaseGraceMs: number): void { + // Whether the last intent we pushed was a hold — i.e. whether there is anything to linger on. + // Stays true through the grace window: the Mac IS still held until the release goes out. + const held = useRef(false); + + useEffect(() => { + if (!desired) { + const release = () => { + held.current = false; + void setAwakeSerialized(false); + }; + if (!held.current || releaseGraceMs <= 0) { + release(); + return; + } + const id = setTimeout(release, releaseGraceMs); + return () => clearTimeout(id); + } + + held.current = true; + const push = async () => { + const res = await setAwakeSerialized(true); + // Only a hold can fail; a release never does. Surface it so the user knows the Mac may + // sleep despite the toggle showing "on". Deduped by message. + if (res.status === "error") { + useAppErrors + .getState() + .pushError("Couldn't keep the Mac awake — it may go to sleep.", res.error); + } + }; + void push(); + // While the assertion is meant to be HELD, re-assert it on a slow heartbeat. This effect + // only re-runs when its inputs change, so if the `caffeinate` child dies out from under us + // while `desired` stays true (killall, an OS reap under pressure) nothing else calls + // set_awake again — the Rust-side liveness prune + respawn in `hold()` only runs when + // invoked. A cheap idempotent re-assert (a no-op while the child is alive) closes that + // self-heal gap. + const id = setInterval(() => void push(), REASSERT_MS); + return () => clearInterval(id); + }, [desired, releaseGraceMs]); +} diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index 039ff0a0..262e9089 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -1108,7 +1108,7 @@ const CAFFEINATE_MODES: Array<{ id: CaffeinateMode; label: string; desc: string { id: "light", label: "Light — follow the agents", - desc: "Keeps the Mac awake only while an agent is working — a running turn or a background task. As soon as the whole fleet is idle, the Mac is free to sleep. The everyday mode: it never keeps the Mac awake needlessly.", + desc: "Keeps the Mac awake only while an agent is working — a running turn or a background task. Once the whole fleet has been idle for a minute, the Mac is free to sleep — the minute lets an agent pick its work back up after a background task finishes. The everyday mode: it never keeps the Mac awake needlessly.", }, { id: "hard", diff --git a/src/store/caffeinate.test.ts b/src/store/caffeinate.test.ts index 47d0e216..152c8280 100644 --- a/src/store/caffeinate.test.ts +++ b/src/store/caffeinate.test.ts @@ -1,5 +1,20 @@ import { describe, expect, it } from "vitest"; -import { caffeineDesired } from "./caffeinate"; +import { caffeineDesired, LIGHT_RELEASE_GRACE_MS, releaseGraceMs } from "./caffeinate"; + +describe("releaseGraceMs", () => { + it("lingers only for Light's activity-driven release", () => { + expect(releaseGraceMs(true, "light")).toBe(LIGHT_RELEASE_GRACE_MS); + }); + + it("releases at once when the user turns Caffeinate off", () => { + expect(releaseGraceMs(false, "light")).toBe(0); + expect(releaseGraceMs(false, "hard")).toBe(0); + }); + + it("does not linger in Hard (its only release is being turned off)", () => { + expect(releaseGraceMs(true, "hard")).toBe(0); + }); +}); describe("caffeineDesired", () => { it("never holds when disabled, whatever the mode or activity", () => { diff --git a/src/store/caffeinate.ts b/src/store/caffeinate.ts index b6850e47..d33aeae9 100644 --- a/src/store/caffeinate.ts +++ b/src/store/caffeinate.ts @@ -13,8 +13,8 @@ const STORAGE_KEY = "tosse:caffeinate"; /** How aggressively the Mac is kept awake while Caffeinate is ON. * - `light`: keep awake only while an agent is actively working — a running turn OR a - * running background task. When the whole fleet is idle, let the Mac sleep. Auto, - * ref-counted on fleet activity. + * running background task. Once the whole fleet has been idle for + * {@link LIGHT_RELEASE_GRACE_MS}, let the Mac sleep. Auto, ref-counted on fleet activity. * - `hard` : keep awake permanently while ON, independent of activity — for Scheduled * Tasks that may fire while nothing is running. Released only when Caffeinate is OFF. */ export type CaffeinateMode = "light" | "hard"; @@ -65,6 +65,24 @@ export function caffeineDesired( return mode === "hard" || anyAgentActive; } +/** How long Light mode keeps holding the Mac awake after the fleet goes idle. + * + * "Idle" is not always idle: when a background task finishes, the CLI starts a follow-up turn + * on its own to handle the ``, but that turn only reads as busy at its + * first `message_start` — seconds later on a large context. The task's completion drops the + * activity count to zero in between, and a Mac idle past its sleep timer goes to sleep + * within ~5 s of the release (seen 2026-09-18: slept 1 s before the re-hold, mid-turn). + * The grace window bridges that gap; the cost is the Mac staying up one extra minute. */ +export const LIGHT_RELEASE_GRACE_MS = 60_000; + +/** Pure: how long to keep holding before a release takes effect. Only Light mode lingers — + * that release follows fleet activity, which has the gap above. Turning Caffeinate OFF is + * the user saying "let it sleep" and releases at once (so does Hard, whose only release IS + * being turned off). */ +export function releaseGraceMs(enabled: boolean, mode: CaffeinateMode): number { + return enabled && mode === "light" ? LIGHT_RELEASE_GRACE_MS : 0; +} + interface CaffeinateState extends CaffeinatePrefs { /** Patch one or more prefs and persist. */ set: (patch: Partial) => void; From cfd6033a5d0309e916dd385df8cde86ac66dd0ab Mon Sep 17 00:00:00 2001 From: Alexandre Josien Date: Fri, 18 Sep 2026 13:24:43 +0200 Subject: [PATCH 5/5] docs(changelog): add the settings reorg and caffeinate fix to v2.3.1 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5548dae4..0efe8ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ by `release.yml` and stays **only** on the GitHub page — it does not appear in ## v2.3.1 - Fix: **artifacts published by Claude open again** — inline artifact cards no longer show "Unavailable", and artifact links in Claude's replies are back to clickable cards (Claude Code 2.1.272 changed the artifact link format). +- Fix: with Caffeinate in **Light** mode, the Mac no longer falls asleep in the short gap between a background task finishing and Claude picking the work back up — it now waits 60 s before letting the Mac sleep. +- Change: **Settings have fewer tabs** — **General** (Accounts · System), a new **Display** tab (appearance, thread, durations, composer, models, ordering) and **Claude Code** (instructions, behavior, helpers). Nothing changes in what the settings do; use the settings search to find one that moved. ## v2.3.0