diff --git a/CHANGELOG.md b/CHANGELOG.md index c3235e65..0efe8ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ 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). +- 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 - 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", 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/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/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; 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/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..262e9089 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/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; 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) =>