diff --git a/.claude/commands/code-audit.md b/.claude/commands/code-audit.md index 380233c..eace5f9 100644 --- a/.claude/commands/code-audit.md +++ b/.claude/commands/code-audit.md @@ -48,9 +48,9 @@ Cross-reference both reports. For each finding: - Re-run linting to verify fixes compile - Append findings to `docs/audits/dev.md` -### Step 5: Converge (only if invoked with `converge`) +### Step 5: Converge (only if invoked with `--converge`) -If the user ran `/code-audit converge`: +If the user ran `/code-audit --converge`: Repeat steps 1-4 until a round finds **zero confirmed issues**. Each round: 1. Re-run both auditor agents (they must re-read the code — previous fixes may have introduced new issues) diff --git a/.claude/commands/ui-audit.md b/.claude/commands/ui-audit.md index c6f92c5..5793a5e 100644 --- a/.claude/commands/ui-audit.md +++ b/.claude/commands/ui-audit.md @@ -8,9 +8,9 @@ Perform a pedantic UI design and accessibility audit. Act as a senior UI designe 4. Run `bunx biome check --write` and `bun run test` to verify 5. Update `docs/audits/ui.md` with findings -### Converge (only if invoked with `converge`) +### Converge (only if invoked with `--converge`) -If the user ran `/ui-audit converge`: +If the user ran `/ui-audit --converge`: Repeat steps 1-5 until a round finds **zero issues**. Each round: 1. Re-read all files (previous fixes may have introduced new issues) diff --git a/CLAUDE.md b/CLAUDE.md index 87e697a..3c64b17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,13 +33,94 @@ src-tauri/src/commands.rs Tauri IPC commands (themes, windows, sound) src-tauri/src/metadata.rs Local metadata store (rename, archive, delete) ``` +## Activity detection & dock badge + +Session activity is tracked via Claude Code hooks and PTY events. This system has been +through multiple iterations — do not simplify without understanding the full state machine. +Full details in `docs/activity-detection.md`. + +### New session creation + +`handleNewSession` spawns a PTY with a temporary ID (`new-{timestamp}`), renders it +immediately via `TerminalPane`, then rekeys to the real session UUID once the backend +discovers the JSONL file. The `pendingPty` state tracks the temp ID, cwd, and a snapshot +of existing session IDs (used to match the new session by cwd basename exclusion). +The rekey effect polls at 200ms until discovery, then calls `pty_rekey` which atomically +updates the reader thread's event prefix via a shared `Arc>`. + +### State machine (`usePtyActivity.ts`) — XState + +Each session gets an independent XState actor. The machine has 5 states: + +``` +idle ──PROMPT──→ computing ──STOP(no agents)──→ draining ──1.5s──→ waiting + │ │ + ├──STOP(agents>0)──→ agentWait │ + │ │ │ + ├──60s idle──→ waiting ├──AGENT_DONE(count=0)──→ draining + │ ├──PTY_DATA/STOP (reenter)│ + └──PTY_DATA──→ computing (reenter, resets timer) │ + │ +waiting ──PROMPT──→ computing │ + * ──EXIT──→ idle │ +``` + +Key design decisions: +- `draining` state: after a non-agent Stop, PTY_DATA is IGNORED (no transition). + This prevents streaming output from re-entering computing and fighting the timer. +- `agentWait` state: tracks running agent count via PreToolUse(Agent/Task) increments + and SubagentStop decrements. Only transitions to draining when count reaches 0. + PTY_DATA and STOP reenter (no-op, keeps state alive). +- `computing` reenter on PTY_DATA: resets the idle timeout timer. +- `hasRunningAgents` guard: checked via a mutable Map outside the machine. +- Agent count is cleared on EXIT to prevent stale counts affecting future sessions. + +The `toActivityState` mapper collapses internal states for the UI: +- computing, draining, agentWait → "computing" (snake border) +- waiting → "waiting" (green dot) +- idle → null (no entry in activityMap) + +### Cleanup effect (`App.tsx`) + +Removes stale session IDs from group slots when the session list changes. Builds a +valid ID set from discovered sessions + pending PTY temp ID, then nulls slots with +unknown IDs. Protects against archived/deleted sessions lingering in groups. + +### Unread tracking (`App.tsx`) + +A session becomes "unread" when it transitions computing→waiting AND: +- It is not the currently selected session, OR +- The window is not focused (`windowFocusedRef.current === false`) + +The second condition is critical — without it, the focused session never becomes +unread when the user Cmd+Tabs away, so the dock badge never shows. + +### Dock badge (`App.tsx` + `commands.rs`) + +- Uses macOS Cocoa API via `objc2` crate (`NSDockTile.setBadgeLabel`) +- Must run on main thread (`app.run_on_main_thread`) +- Window focus tracked via Tauri's `onFocusChanged` (not DOM focus/blur — those + fire on webview-internal focus changes, causing visual glitches) +- On focus regained: badge cleared AND selected session marked as read +- `unreadCountRef` (not state) used in focus handler to avoid re-renders + +### Computing border animation (`index.css`) + +Uses conic-gradient rotation on a real `
` element (not `::before`). +The mask-composite CSS technique does NOT work in Tauri's WKWebView. +Instead, the gradient div extends 4px outside the pane (`inset: -4px`, +`border-radius: 10px`) and the inner pane's solid background covers +the center. The `@property --cm-angle` must use the `--cm-` prefix +to avoid collision with Tailwind v4's `@property` fallback layer. + ## Code style — TypeScript - All `if` statements must use curly braces, even single-line - Biome handles formatting (tabs, double quotes, 100 char width) and linting - Run `bunx biome check --write` to format - Pure logic belongs in `sidebarUtils.ts` or `groupOps.ts`, not in components -- Inline styles, not CSS classes (except index.css for global/keyframe rules) +- Tailwind utility classes for layout (flex, grid, padding, etc.); inline styles for dynamic/theme values +- index.css for global/keyframe rules only - No `any` types. Prefer `unknown` and narrow. - Tests go next to source files (`foo.test.ts` alongside `foo.ts`) diff --git a/README.md b/README.md index b9c7288..ffa4815 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ The session manager for Claude Code. Manage multiple sessions in one window with live status, named groups, and persistent workspaces. +> Not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic PBC. + ## Why Claude Manager? **Organise by role.** Name sessions "planner", "implementer", "reviewer" and group them together. Drag to rearrange, switch between project contexts in one click. @@ -150,6 +152,8 @@ Right-click a session in the sidebar to access these actions: ### Groups and tiling - Drag sessions onto a group header to add them. If the group is full, it auto-expands to the next enabled tiling layout. +- Drag a group header onto another group to reorder them. A line shows the insertion point. +- Drag a grouped session onto the sessions list to ungroup it. - Change tiling layouts from the group header in the sidebar. - Enable/disable layouts in Settings > Preferences > Tiling Layouts. diff --git a/bun.lock b/bun.lock index 1b966de..d74df40 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,14 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-opener": "^2", + "@xstate/react": "^6.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "cmdk": "^1.1.1", "react": "^19.2.5", "react-dom": "^19.2.5", + "xstate": "^5.30.0", }, "devDependencies": { "@biomejs/biome": "^2.4.11", @@ -362,6 +364,8 @@ "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + "@xstate/react": ["@xstate/react@6.1.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "xstate": "^5.28.0" }, "optionalPeers": ["xstate"] }, "sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], @@ -546,8 +550,12 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], @@ -566,6 +574,8 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xstate": ["xstate@5.30.0", "", {}, "sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw=="], + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], diff --git a/docs/activity-detection.md b/docs/activity-detection.md new file mode 100644 index 0000000..c6c96fb --- /dev/null +++ b/docs/activity-detection.md @@ -0,0 +1,334 @@ +# Activity Detection + +How Claude Manager tracks session lifecycle, activity state, and notifications. + +## New Session Creation Lifecycle + +Creating a new session involves a multi-step handoff between frontend and backend. + +### Step-by-step flow + +1. **User clicks "New Session"** — `handleNewSession(cwd)` in `App.tsx`: + - Generates a temporary ID: `new-{timestamp}` + - Records `pendingPty = { tmpId, cwd, existingIds }` — snapshots all current session IDs + - Sets `standaloneSelectedId` to the temp ID + - Calls `pty_spawn` with `resume: false` (no `--resume` flag) + +2. **`pty_spawn` in Rust** (`pty_manager.rs`): + - Validates the ID (alphanumeric + hyphens only) + - Acquires a lockfile at `~/.claude/manager/locks/{id}.lock` + - Opens a PTY via `portable-pty`, spawns `$SHELL -l -c "claude"` with the given cwd + - Spawns a reader thread that: + - On first output, emits `sessions-changed` at 1s and 3s delays (gives Claude time + to write its pid file) + - Continuously reads PTY output, appends to a 512KB scrollback buffer + - Emits `pty-data-{id}` (base64-encoded) for each chunk + - On EOF/error, emits `pty-exit-{id}`, removes the PTY entry (if it still belongs + to this spawn instance), releases the lockfile, and emits `sessions-changed` + +3. **`TerminalPane` mounts** with `ptyId = "new-{timestamp}"`: + - Creates an xterm.js terminal + - Calls `pty_get_scrollback` — finds the entry (just spawned), replays any buffered output + - Subscribes to `pty-data-{ptyId}` for live output + - Pipes user input to `pty_write` + +4. **Claude Code creates its JSONL file** — happens after Claude initializes (~1-3s): + - Claude writes `~/.claude/sessions/{pid}.json` (pid file) + - Claude writes `~/.claude/projects/{project}/{uuid}.jsonl` (conversation file) + +5. **Backend discovers the new session** (`sessions.rs:get_all_sessions`): + - `useSessions` polls every 3s, plus reacts to `sessions-changed` events + - The rekey effect in `App.tsx` polls aggressively at 200ms while `pendingPty` exists + - Matches the new session by finding one whose `session_id` is NOT in `existingIds` + AND whose cwd basename matches `pendingPty.cwd` + +6. **`pty_rekey`** (`pty_manager.rs`): + - Removes the PTY entry keyed by `new-{timestamp}` + - Updates the `event_id` Arc to the real session UUID — the reader thread immediately + starts emitting events under the new ID + - Re-inserts the entry keyed by the real UUID + - Transfers the lockfile from temp ID to real ID + +7. **Frontend state update** (after rekey resolves): + - `standaloneSelectedId` switches to the real UUID + - `pendingPty` is cleared + - `TerminalPane` unmounts (keyed on old ptyId) and remounts with the real ID + - On remount, `pty_get_scrollback` replays the full scrollback — no output is lost + +### Why pty_rekey exists + +When the user creates a new session, Claude Code hasn't started yet — there is no session +UUID. The PTY must be spawned immediately (the user sees the terminal), but the PTY map +and event system need a key. We use a temporary `new-{timestamp}` key, then atomically +rekey to the real UUID once discovered. The `event_id` Arc inside the reader thread means +the thread never needs to be restarted — it just starts emitting under the new name. + +### The scrollback identity check + +When the reader thread exits, it must remove the PTY entry from the map. But if `pty_spawn` +was called again for the same ID before the old reader exits, the new entry would have a +different `scrollback` Arc. The reader uses `Arc::ptr_eq` to verify it's removing its own +entry, not the replacement. + +## Session Discovery (`sessions.rs`) + +`get_all_sessions(config_dir)` builds the session list in three steps: + +1. **Scan pid files** (`~/.claude/sessions/*.json`): Find alive processes, build a + `cwd -> pid` map. Also collect sessions that have a pid file but no JSONL yet + (freshly spawned). + +2. **Scan JSONL files** (`~/.claude/projects/*/*.jsonl`): Read the first valid line + for `sessionId`, `cwd`, `timestamp`, `gitBranch`. This is the primary source — + sessions only appear in the UI once they have a JSONL file. Pid-only sessions + (alive process, no JSONL) are added as a fallback for freshly spawned sessions. + +3. **Merge and sort**: For each cwd with a live process, the most recent JSONL session + in that directory is marked Active. Offline sessions are capped at 50. Final sort: + active first, then offline, newest first within each group. + +### liveSessions (`App.tsx`) + +The frontend's `liveSessions` memo overrides session status based on local PTY state: +if a session has PTY activity (`activityMap` or `alivePtys`) but the backend says "offline", +it's promoted to "active". This handles the window between PTY spawn and the next session +poll. Ignored sessions (per user's ignore patterns) are filtered out here. + +## Polling and Events (`useSessions.ts`) + +Sessions are refreshed via three mechanisms: +- **3-second poll interval** — catches changes from external processes +- **`sessions-changed` event** — emitted by PTY reader on first output (1s/3s delays) + and on PTY exit +- **200ms aggressive poll** — only while `pendingPty` is active (the rekey effect) + +## Activity State Machine (XState) + +Each session gets an independent XState actor (`usePtyActivity.ts`). Five states: + +``` + +--PTY_DATA (reenter, reset idle timer) + | +idle --PROMPT--> computing --STOP(agents=0)--> draining --1.5s--> waiting + | | + +--STOP(agents>0)--> agentWait | + | | | + +--60s idle--> waiting +--PTY_DATA/STOP(reenter)| + | +--AGENT_DONE & count=0 | + | | --> draining | + | +--PROMPT --> computing | + | | +waiting --PROMPT--> computing | + * --EXIT--> idle | +``` + +### State descriptions + +| State | UI | Meaning | +|---|---|---| +| `idle` | No indicator | No PTY activity tracked | +| `computing` | Snake border | Claude is actively responding | +| `draining` | Snake border | Stop received, waiting for streaming to finish (1.5s) | +| `agentWait` | Snake border | Agents are running, waiting for all to complete | +| `waiting` | Green dot | Claude finished, awaiting user input | + +### Transitions + +| From | Event | Guard | To | +|---|---|---|---| +| idle | PROMPT | | computing | +| computing | STOP | hasRunningAgents | agentWait | +| computing | STOP | !hasRunningAgents | draining | +| computing | PTY_DATA | | computing (reenter) | +| computing | EXIT | | idle | +| computing | (60s timeout) | | waiting | +| draining | PROMPT | | computing | +| draining | EXIT | | idle | +| draining | (1.5s timeout) | | waiting | +| agentWait | AGENT_DONE | | draining | +| agentWait | PTY_DATA | | agentWait (reenter) | +| agentWait | STOP | | agentWait (reenter) | +| agentWait | PROMPT | | computing | +| agentWait | EXIT | | idle | +| waiting | PROMPT | | computing | +| waiting | EXIT | | idle | + +### Key invariants + +1. **`draining` ignores PTY_DATA.** After a non-agent Stop, streaming output must not + re-enter computing. This was the root cause of "stuck computing" — streaming output + after Stop would re-enter computing, cancelling the drain timer indefinitely. + +2. **`agentWait` tracks agent count.** `PreToolUse(Agent/Task)` increments a counter, + `SubagentStop` decrements. Only when count reaches 0 does `AGENT_DONE` fire, + transitioning to `draining`. Prevents premature "waiting" when one agent finishes + but others are still running. + +3. **`computing` reenter resets idle timer.** PTY output proves work is happening, + so the 60s idle fallback restarts on every chunk. + +4. **`hasRunningAgents` guard** on the `computing -> agentWait` transition checks + `agentCount.get(id) > 0`. The count lives in a mutable Map outside the machine, + not in machine context. + +5. **`agentWait` PTY_DATA and STOP reenter** — these are no-ops that keep the state + alive. Without reenter, the events would be silently dropped (correct behavior), + but reenter is explicit about the intent. + +6. **Agent count is cleared on EXIT.** When the PTY exits, `agentCount.delete(id)` + prevents stale counts from affecting a future session with the same ID. + +### UI mapping + +The `toActivityState` function collapses internal states for the UI: +- `computing`, `draining`, `agentWait` -> `"computing"` (snake border animation) +- `waiting` -> `"waiting"` (green dot, unread blue dot if not focused) +- `idle` -> `null` (no entry in activityMap) + +## Hook Events + +Claude Code fires hooks at key lifecycle points. Claude Manager installs hooks into each +profile's `settings.json` to POST events to a local HTTP server (port 23816). + +### Events we listen for + +| Hook event | Tauri event | Purpose | +|---|---|---| +| `UserPromptSubmit` | `hook-computing-{id}` | User submitted a prompt -> enter computing | +| `Stop` | `hook-stop-{id}` | Claude finished responding -> start drain timer | +| `PreToolUse` (Agent/Task) | `hook-agentlaunched-{id}` | Agent is about to be spawned | +| `SubagentStop` | `hook-agentdone-{id}` | A subagent completed | + +### Events we don't use (but exist) + +- `SubagentStart` — fires when agent spawns, but `PreToolUse` fires first and is sufficient +- `PostToolUse` — after any tool completes +- `SessionStart` / `SessionEnd` — session lifecycle +- `PreCompact` / `PostCompact` — context compaction + +### How Claude Code handles subagents + +All subagent hooks share the **same `session_id`** as the parent session. Each subagent +gets a unique `agent_id` in the hook payload but we don't use it — we only need to know +how many are running. + +### Event sequence: user prompt -> 2 sequential agents -> final response + +``` +1. UserPromptSubmit <- enter computing +2. PreToolUse (Agent #1) <- agentCount++ +3. SubagentStart (#1) (not used) +4. [Agent #1 runs tools] +5. SubagentStop (#1) <- agentCount-- +6. PreToolUse (Agent #2) <- agentCount++ +7. SubagentStart (#2) (not used) +8. [Agent #2 runs tools] +9. SubagentStop (#2) <- agentCount-- +10. Stop <- agentCount=0, so -> draining -> 1.5s -> waiting +``` + +### Event sequence: user prompt -> 2 background agents -> final response + +When agents run with `run_in_background: true`, they may overlap: + +``` +1. UserPromptSubmit <- enter computing +2. PreToolUse (Agent #1) <- agentCount++ +3. PreToolUse (Agent #2) <- agentCount++ +4. SubagentStop (#1) <- agentCount-- (count=1, still >0) +5. SubagentStop (#2) <- agentCount-- (count=0) +6. Stop <- agentCount=0, so -> draining -> 1.5s -> waiting +``` + +Key: the `Stop` event fires only ONCE at the end of the entire turn, after all agents +complete. But intermediate PTY output and tool events continue while agents run. + +## Hook Installation + +`hook_server.rs:install_hooks()` runs at app startup. For each profile's config dir, +it merges hook entries into `settings.json`. It checks for existing entries that already +contain the hook URL to avoid duplicates. The hook command differs by platform: +- **macOS/Linux**: `curl -sf --max-time 2 -X POST http://127.0.0.1:23816/hook -H 'Content-Type: application/json' -d @- || true` +- **Windows**: `powershell -NoProfile -Command "try { $input | Invoke-WebRequest ... } catch {}"` + +The server validates that connections come from localhost only (loopback check on peer +address) and validates session IDs before emitting events. + +## Unread Tracking + +A session becomes "unread" (`App.tsx`) when it transitions computing->waiting AND: +- It is not the currently selected session, OR +- The window is not focused (`windowFocusedRef.current === false`) + +The window focus condition is critical — without it, the focused session never becomes +unread when the user Cmd+Tabs away, so the dock badge never shows. + +Unread is cleared when: +- The session is selected (`selectedId` effect) +- The user types in the session's PTY (`onInput` callback from `usePtyActivity`) +- The window regains focus and the session is currently selected + +## Dock Badge + +The dock badge shows the unread session count: +- Uses macOS Cocoa API via `objc2` crate (`NSDockTile.setBadgeLabel`) +- Must run on main thread (`app.run_on_main_thread`) +- Window focus tracked via Tauri's `onFocusChanged` (not DOM focus/blur — those + fire on webview-internal focus changes, causing visual glitches) +- Badge is set when `unreadSessions` changes and window is not focused +- On focus regain: badge cleared AND selected session marked as read +- `unreadCountRef` (not state) used in focus handler to avoid re-renders + +### Notification sound + +When a session transitions computing->waiting and would be marked unread, a notification +sound is played if enabled via `notif-sound-enabled` and `notif-sound-path` localStorage +settings. Uses the Tauri `play_sound` command (which invokes `afplay` on macOS). + +## Computing Border Animation + +Uses a conic-gradient on a real `
` element (not `::before` pseudo-element). +The CSS mask-composite technique does NOT work in Tauri's WKWebView. + +Instead: +- `.computing-border` div extends 4px outside the pane (`inset: -4px`, `border-radius: 10px`) +- Inner pane's solid background covers the center +- `@property --cm-angle` must use the `--cm-` prefix to avoid collision with + Tailwind v4's `@property` fallback layer which resets `--border-angle` + +## Cleanup Effect (Group Slot Eviction) + +The cleanup effect in `App.tsx` (line ~221) removes stale session IDs from group slots: +- Triggers on session list changes (not group changes) +- Builds a set of valid IDs: all discovered sessions + pending PTY temp ID +- Nulls out any group slot whose session ID is not in the valid set +- Groups with all-null slots after cleanup are NOT pruned here (only `dropToSlot`, + `removeFromGroup`, and `removeFromSlot` prune empty groups) + +This protects against: archived sessions lingering in groups, deleted sessions leaving +ghost slots, sessions that disappear from discovery. + +## Pending Rename Flush + +When a session transitions to "waiting" (computing->waiting or any->waiting), if it has +a `pending_rename` in metadata and has a live PTY, the app writes `/rename {name}\r` to +the PTY and clears the pending rename. This allows rename-on-next-idle behavior. + +## Known Edge Cases + +1. **Rekey race**: If the user creates two sessions in the same cwd rapidly, the rekey + match (by cwd basename) could match the wrong session. Mitigated by the `existingIds` + snapshot — only sessions not in the snapshot are candidates. + +2. **Port conflict**: If port 23816 is already bound, the hook server silently no-ops. + Activity detection falls back to the 60s idle timer (no hook events). + +3. **Multiple windows**: Each window runs its own hook server attempt. Only one binds + the port. Lock files prevent two windows from resuming the same session, but hook + events are broadcast to whichever window's server is running. + +4. **PTY exit during rekey**: If the PTY exits between spawn and rekey, `handlePtyExit` + clears `pendingPty` if it matches the temp ID, preventing a stuck pending state. + +5. **Scrollback overflow**: The 512KB scrollback buffer is a ring — excess bytes are + drained from the front. Long-running sessions lose early output. diff --git a/docs/audits/dev.md b/docs/audits/dev.md index 8eb07d2..808a633 100644 --- a/docs/audits/dev.md +++ b/docs/audits/dev.md @@ -1,6 +1,6 @@ # Code Audit Report -**Last audit:** 2026-04-14 (4 rounds, dual-reviewer process) +**Last audit:** 2026-04-22 (round 14, converged, dual-reviewer process) **Standard:** DRY, SOLID, clean code, correctness, safety **Status: PASS** -- all confirmed issues resolved @@ -192,3 +192,148 @@ No fixes applied. | Biome exhaustive-deps warnings | Intentionally limited deps to prevent re-subscription loops | | `/users/` lowercase in `isSessionIgnored` | Input is `.toLowerCase()`'d first on the line above | | Layout constants defined per-component | Different data (CSS grid positions vs icon positions vs templates) | +| Tailwind utility classes in components | Convention: Tailwind for layout, inline styles for dynamic/theme values | + +--- + +## Round 5 — 2026-04-18 + +Dual-reviewer audit (Correctness & Safety + Architecture & Design). + +### Fixed (10) + +| # | File | Issue | Fix | +|---|------|-------|-----| +| 1 | `pty_manager.rs:80` | Session ID not validated before shell interpolation | Added alphanumeric+hyphen validation at IPC boundary | +| 2 | `pty_manager.rs:143` | Env var keys from settings.json not validated | Added alphanumeric+underscore check, skip invalid keys | +| 3 | `App.tsx:506` | Control chars in pending_rename written to PTY | Strip chars < 32 and 127 before writing | +| 4 | `Sidebar.tsx:1342` | Inline display name logic duplicates `sessionDisplayName()` | Replaced with shared util call | +| 5 | `Settings.tsx:10` | `TILING_OPTIONS` duplicates `LAYOUT_ORDER` from groupOps | Import from groupOps | +| 6 | `Sidebar.tsx:879` | Redundant `autoCorrect`/`autoCapitalize`/`spellCheck` after `{...noAutocorrect}` spread | Removed duplicates | +| 7 | `MainPane.tsx:31` | Unnecessary `useEffect` setting state to same initial value | Removed | +| 8 | `usePtyActivity.ts:27` | Plain objects used instead of `useRef` for callback refs | Changed to `useRef` | +| 9 | `commands.rs:72` | Profile ID not URL-encoded in window URL | Added percent-encoding | +| 10 | `App.tsx:31` | `Math.random()` for group IDs | Changed to `crypto.randomUUID()` | + +### Discarded + +| Finding | Reason | +|---------|--------| +| TOCTOU race in lock acquisition | Requires two instances within same millisecond; risk is negligible | +| Hook server fixed port 23816 | Known limitation; dynamic port would require service discovery | +| `is_pid_alive` not Windows-portable | Windows support is untested; will address when Windows CI is added | +| `metadata.rs` save() silent failures | Matches existing error-handling pattern; metadata is non-critical | +| `parse_timestamp` leap year approximation | Accepted pattern per checklist | +| Sidebar/Settings/App component length | Large but well-structured; extraction would add indirection without clear benefit | +| `theme.terminal` in TerminalPane deps | Intentional: ensures terminal recreates with correct theme on profile switch | +| Layout cell data duplication | Different shapes per component (CSS strings vs icon grids); shared constant wouldn't simplify | +| `hook_server.rs` settings.json no atomic write | Localhost-only, single writer; risk is negligible | +| `sessions.rs` alive_cwd_pids overwrite on dup cwd | Only one Claude process per cwd in practice | + +--- + +## Round 6 — 2026-04-18 (convergence round 1) + +Windows support in scope. Dual-reviewer audit. + +### Fixed (9) + +| # | File | Issue | Fix | +|---|------|-------|-----| +| 1 | `utils.rs:5` | `is_pid_alive` uses Unix `kill -0`, broken on Windows | Added `#[cfg(unix)]`/`#[cfg(windows)]` with `tasklist` fallback | +| 2 | `pty_manager.rs:86` | Tilde expansion only handles `~/`, not `~\` | Added `~\\` check for Windows paths | +| 3 | `pty_manager.rs:138-171` | Env prefix `KEY=val cmd` is bash-only, breaks on Windows | Replaced with `cmd_builder.env()` on all platforms | +| 4 | `journal.rs:95` | `encode_path_for_claude` strips `/` only, not `\` or drive letters | Handle both separators and drive prefix | +| 5 | `hook_server.rs:10` | Hook command uses `curl \|\| true` (bash-only) | Added Windows PowerShell `Invoke-WebRequest` variant | +| 6 | `StatusDot.tsx` | Base styles repeated across all 4 branches | Extracted shared `base` style object | +| 7 | `utils.ts:10` | `formatCwd` only handles `/Users/`, not Linux `/home/` | Added `/home/` to regex | +| 8 | `useSessions.ts:11,37` | `error` state declared but never consumed by callers | Removed | +| 9 | `commands.rs:7` | Custom themes path hardcoded instead of using `manager_config_dir()` | Use shared util | + +## Round 7 — 2026-04-18 (convergence round 2) + +Both auditors reported **zero issues**. Converged. + +--- + +## Round 8 — 2026-04-18 (convergence round 1, fresh) + +Fresh dual-reviewer audit with Windows support in scope. + +### Fixed (5) + +| # | File | Issue | Fix | +|---|------|-------|-----| +| 1 | `utils.rs:16` | `is_pid_alive` Windows tasklist substring false positives | Use CSV format and exact PID column match | +| 2 | `journal.rs:95` | `encode_path_for_claude` greedy trim strips valid leading chars | Only strip drive prefix (letter + colon), not all leading alpha | +| 3 | `utils.ts:14` | `pathBasename` only splits on platform separator, misses mixed paths | Split on both `/` and `\` | +| 4 | `App.tsx:505` | rename-on-waiting fires from any window, not just PTY owner | Guard with `alivePtys.has(session.session_id)` | +| 5 | `Sidebar.tsx:443` | control-char filter missing from `commitRename` (only in auto-rename) | Added same filter to `commitRename` | + +### Discarded + +| Finding | Reason | +|---------|--------| +| cmd.exe /C shell wrapping | User controls their own shell config; claude_cmd is validated | +| profiles_path ~/.config on Windows | Design decision, consistent across app | +| navigator.platform deprecated | Works in Tauri WKWebView/WebView2 today | +| HTTP method validation in hook_server | Localhost only, valid payload required | +| Sessions sort with 0 timestamp | Fallback search handles it | +| macOS modifier glyphs hardcoded | Cosmetic, platform detection throughout is out of scope | + +## Round 9 — 2026-04-18 (convergence round 2) + +Both auditors reported **zero issues**. Converged. + +--- + +## Round 10 — 2026-04-22 (convergence round 1) + +### Fixed (7) + +| # | File | Issue | Fix | +|---|------|-------|-----| +| 1 | `App.tsx:612` | Cmd+W deletes without confirmation | Added `ask()` dialog | +| 2 | `App.tsx:527` | Pending PTY match uses basename only | Full cwd match | +| 3 | `App.tsx:297-338` | 3 handlers bypass persistGroups | Use persistGroups callback | +| 4 | `utils.ts:7` | formatCwd only handles C:\ drive | Any drive letter `[A-Z]` | +| 5 | `Settings.tsx:201` | Hotkeys table says "Archive" | Updated to "Delete" | +| 6 | `Settings.tsx:1388` | Guide lists "Archive" action | Replaced with "Rename" | +| 7 | `App.tsx:96` | handlePtyExit doesn't filter empty groups | Added `.filter()` | + +## Round 11 — 2026-04-22 (convergence round 2) + +Both auditors reported **zero issues**. Converged. + +--- + +## Round 12 — 2026-04-22 + +### Fixed (2) + +| # | File | Issue | Fix | +|---|------|-------|-----| +| 1 | `MainPane.tsx:50` | Empty state hint says "press N" but binding is ⌘T | Updated to "⌘T to start a new session" | +| 2 | `hook_server.rs:36` | No read timeout on TCP stream — slow client holds thread | Added 5s read timeout | + +### Converged — zero remaining actionable issues. + +--- + +## Round 13 — 2026-04-22 + +### Fixed (3) + +| # | File | Issue | Fix | +|---|------|-------|-----| +| 1 | `App.tsx:269` | handleDeleteGroup unconditionally removes active group from localStorage | Moved inside setActiveGroupId callback, gated on `prevActive === id` | +| 2 | `tauri.conf.json:15` | Window title "claude-manager" mismatches "ClaudeManager" in new_window | Changed to "ClaudeManager" | +| 3 | `pty_manager.rs:296` | Dead `pty-input-{id}` emit on every keystroke, never listened to | Removed emit and unused `app` parameter from `pty_write` | + +### Converged. + +--- + +## Round 14 — 2026-04-22 + +Combined audit (correctness + architecture). **No issues found.** Converged. diff --git a/docs/audits/ui.md b/docs/audits/ui.md index af50966..219f990 100644 --- a/docs/audits/ui.md +++ b/docs/audits/ui.md @@ -1,6 +1,6 @@ # UI Design & Accessibility Audit -**Last audit:** 2026-04-17 +**Last audit:** 2026-04-22 **Standard:** Visual design consistency + WCAG 2.2 Level AA **Status: PASS** -- all confirmed issues resolved @@ -456,3 +456,55 @@ Full WCAG audit history in `docs/audits/wcag2-audit.md`. Summary: | 3.3.2 Form labels | PASS (aria-labels added to DEFAULT SHELL and profile name inputs) | | 3.3.7 Redundant entry | PASS | | 4.1.2 ARIA labels | PASS | + +--- + +## Round 14 — 2026-04-18 + +### Fixed (5) + +| # | Issue | Fix | +|---|-------|-----| +| 1 | `role="document"` on inner modal divs (3 files) — non-standard, causes noStaticElementInteractions | Removed role attribute | +| 2 | `fontSize: 9` on hidden profiles separator label — below minimum scale | Changed to 10 | +| 3 | Focus trap doesn't restore focus to previously focused element (WCAG 2.4.3) | Store `document.activeElement` on mount, restore on cleanup | +| 4 | ~~Tailwind utility classes mixed with inline styles in MainPane~~ | Reverted — Tailwind is the convention for layout classes | +| 5 | Biome formatting drift from edits | Auto-formatted | + +### Discarded + +| Finding | Reason | +|---------|--------| +| paddingTop: 120 on command palette | Intentional positioning for command palette feel | +| Settings modal centered vs command palette top-anchored | Intentional: settings = centered, palettes = top | +| Hardcoded logo colors | Branding colors, theme-independent by design | +| Sub-4px borderRadius on decorative elements (1, 2) | Decorative sub-scale elements | +| fontSize: 24 on grid slot "+" icon | Display/decorative size | +| maxHeight on scroll containers (320, 360) | Scroll container sizing, not spacing | +| Logo SVG 22px | Set by user, intentional | +| Tab font weight differences | Intentional per component context | +| z-index layering (50 vs 1000) | Works correctly in practice | +| Profile pill borderRadius 4 | Design choice | + +--- + +## Round 15 — 2026-04-22 + +### Fixed (6) + +| # | Issue | Fix | +|---|-------|-----| +| 1 | `modalBackdropStyle` z-index 50 vs Settings 1000 | Standardized to 1000 | +| 2 | Settings modal missing `backdropFilter: "blur(4px)"` | Added blur to match shared style | +| 3 | NewSessionModal padding "20px 12px" off-scale | Changed to "24px 12px" | +| 4 | `.computing-border` border-radius 10px off-scale | Changed to 8px | +| 5 | Filter dropdown padding "6px 0" inconsistent with "4px 0" | Standardized to "4px 0" | +| 6 | Layout picker padding "6px" inconsistent | Changed to "4px" | + +### Discarded + +| Finding | Reason | +|---------|--------| +| tabpanel roles on Settings/MainPane/CommandPalette tabs | Low priority, tabs work via focus trap | +| Context menu menuitem roles | Already present (auditor error) | +| Separator role with aria-value attributes | Valid per ARIA spec for focusable separators | diff --git a/index.html b/index.html index eebdfa4..d1922ef 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - claude-manager + ClaudeManager diff --git a/install.sh b/install.sh index bd1901e..5bf9d31 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,8 @@ set -e REPO="JeffreyWardman/claude-manager" -APP_NAME="claude-manager" +APP_NAME="ClaudeManager" +PKG_NAME="claude-manager" # Detect platform and architecture OS="$(uname -s)" @@ -66,7 +67,7 @@ elif [ "$PLATFORM" = "linux" ]; then # Prefer .deb on Debian/Ubuntu, fall back to AppImage if command -v dpkg >/dev/null 2>&1; then - DEB="${APP_NAME}_${VERSION}_amd64.deb" + DEB="${PKG_NAME}_${VERSION}_amd64.deb" URL="$BASE_URL/$DEB" TMPDIR=$(mktemp -d) DEB_PATH="$TMPDIR/$DEB" @@ -82,17 +83,17 @@ elif [ "$PLATFORM" = "linux" ]; then echo "Installed $APP_NAME $VERSION" echo "CLI commands available: claude-manager, cmanager" else - APPIMAGE="${APP_NAME}_${VERSION}_amd64.AppImage" + APPIMAGE="${PKG_NAME}_${VERSION}_amd64.AppImage" URL="$BASE_URL/$APPIMAGE" INSTALL_DIR="${HOME}/.local/bin" mkdir -p "$INSTALL_DIR" echo "Downloading $APPIMAGE..." - curl -fSL -o "$INSTALL_DIR/$APP_NAME" "$URL" - chmod +x "$INSTALL_DIR/$APP_NAME" + curl -fSL -o "$INSTALL_DIR/$PKG_NAME" "$URL" + chmod +x "$INSTALL_DIR/$PKG_NAME" - ln -sf "$INSTALL_DIR/$APP_NAME" "$INSTALL_DIR/cmanager" - echo "Installed $APP_NAME $VERSION to $INSTALL_DIR/$APP_NAME" + ln -sf "$INSTALL_DIR/$PKG_NAME" "$INSTALL_DIR/cmanager" + echo "Installed $APP_NAME $VERSION to $INSTALL_DIR/$PKG_NAME" echo "CLI commands available: claude-manager, cmanager" echo "Make sure $INSTALL_DIR is in your PATH" fi diff --git a/package.json b/package.json index 15d9834..271c22c 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-opener": "^2", + "@xstate/react": "^6.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "cmdk": "^1.1.1", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "xstate": "^5.30.0" }, "devDependencies": { "@biomejs/biome": "^2.4.11", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 54a5f35..83b1c8a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -468,6 +468,9 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "dirs-next", + "objc2", + "objc2-app-kit", + "objc2-foundation", "portable-pty", "regex", "serde", @@ -477,6 +480,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-shell", ] @@ -1304,6 +1308,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1436,6 +1450,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2302,8 +2334,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", "objc2-foundation", ] @@ -2331,6 +2393,41 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4001,6 +4098,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -5541,6 +5653,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bc1636f..1c906c7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,12 @@ regex = "1.12.3" portable-pty = "0.9" base64 = "0.22" shell-escape = "0.1.5" +tauri-plugin-global-shortcut = "2.3.1" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSDockTile"] } +objc2-foundation = { version = "0.3", features = ["NSString"] } [profile.release] strip = true diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ef12532..5c9f797 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "core:window:allow-start-dragging", "core:window:allow-minimize", "core:window:allow-create", + "core:window:allow-show", "opener:default", "shell:default", "shell:allow-execute", @@ -16,6 +17,9 @@ "fs:allow-read-dir", "fs:allow-exists", "dialog:default", - "dialog:allow-open" + "dialog:allow-open", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister", + "global-shortcut:allow-is-registered" ] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..02c9ae8 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..ec3fbbe 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..b5dc658 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..d6d6b03 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..85423ac 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..0154dcb 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..5d35472 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..51c416d 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..5da61f0 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..8984e5d 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..9751277 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..5909cc7 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..5d49c72 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..1c93e2d 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..29eaa1a 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..b86f06c 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg new file mode 100644 index 0000000..c65ea58 --- /dev/null +++ b/src-tauri/icons/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/mark.svg b/src-tauri/icons/mark.svg new file mode 100644 index 0000000..609787d --- /dev/null +++ b/src-tauri/icons/mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 67f5b54..0fa136c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,11 +1,10 @@ use crate::sessions::{get_all_sessions, ClaudeSession}; +use crate::utils::manager_config_dir; use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder}; #[tauri::command] pub fn get_custom_themes() -> Vec { - let Some(dir) = - dirs_next::home_dir().map(|h| h.join(".config").join("claude-manager").join("themes")) - else { + let Some(dir) = manager_config_dir().map(|d| d.join("themes")) else { return vec![]; }; let Ok(entries) = std::fs::read_dir(&dir) else { @@ -68,12 +67,24 @@ pub fn new_window(app: AppHandle, profile: Option) -> Result<(), String> ); let mut url = String::from("/"); if let Some(ref profile_id) = profile { - url = format!("/?profile={}", profile_id); + let encoded: String = profile_id + .bytes() + .flat_map(|b| { + if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' { + vec![b as char] + } else { + format!("%{:02X}", b).chars().collect() + } + }) + .collect(); + url = format!("/?profile={}", encoded); } let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into())) - .title("claude-manager") + .title("ClaudeManager") .inner_size(1200.0, 800.0) - .min_inner_size(800.0, 500.0); + .min_inner_size(800.0, 500.0) + .visible(false) + .background_color(tauri::window::Color(245, 239, 228, 255)); #[cfg(target_os = "macos")] let builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) @@ -81,3 +92,29 @@ pub fn new_window(app: AppHandle, profile: Option) -> Result<(), String> builder.build().map_err(|e: tauri::Error| e.to_string())?; Ok(()) } + +#[tauri::command] +pub fn set_badge_count(count: Option, app: AppHandle) { + #[cfg(target_os = "macos")] + { + let _ = app.run_on_main_thread(move || unsafe { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + use objc2_foundation::NSString; + + let mtm = MainThreadMarker::new_unchecked(); + let ns_app = NSApplication::sharedApplication(mtm); + let dock_tile = ns_app.dockTile(); + let label = match count { + Some(n) if n > 0 => Some(NSString::from_str(&n.to_string())), + _ => None, + }; + dock_tile.setBadgeLabel(label.as_deref()); + dock_tile.display(); + }); + } + #[cfg(not(target_os = "macos"))] + { + let _ = (count, app); + } +} diff --git a/src-tauri/src/hook_server.rs b/src-tauri/src/hook_server.rs index aede586..b1b7e88 100644 --- a/src-tauri/src/hook_server.rs +++ b/src-tauri/src/hook_server.rs @@ -8,9 +8,15 @@ pub const PORT: u16 = 23816; const HOOK_URL: &str = "http://127.0.0.1:23816/hook"; fn hook_command() -> String { - format!( - "curl -sf --max-time 2 -X POST {HOOK_URL} -H 'Content-Type: application/json' -d @- || true" - ) + if cfg!(target_os = "windows") { + format!( + "powershell -NoProfile -Command \"try {{ $input | Invoke-WebRequest -Uri '{HOOK_URL}' -Method POST -ContentType 'application/json' -TimeoutSec 2 | Out-Null }} catch {{}}\"" + ) + } else { + format!( + "curl -sf --max-time 2 -X POST {HOOK_URL} -H 'Content-Type: application/json' -d @- || true" + ) + } } /// Start the HTTP hook listener. Silently no-ops if the port is already bound. @@ -28,6 +34,7 @@ pub fn start(app: AppHandle) { } fn handle(stream: std::net::TcpStream, app: AppHandle) { + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); if stream .peer_addr() .map(|a| !a.ip().is_loopback()) @@ -91,6 +98,7 @@ fn handle(stream: std::net::TcpStream, app: AppHandle) { "PreToolUse" if matches!(p.tool_name.as_deref(), Some("Agent") | Some("Task")) => { format!("hook-agentlaunched-{}", p.session_id) } + "SubagentStop" => format!("hook-agentdone-{}", p.session_id), _ => return, }; let _ = app.emit(&event, ()); @@ -120,7 +128,7 @@ fn install_for_dir(dir: &std::path::Path, command: &str) -> Option<()> { let hooks_map = hooks_val.as_object_mut()?; let mut changed = false; - for hook_type in &["UserPromptSubmit", "PreToolUse", "Stop"] { + for hook_type in &["UserPromptSubmit", "PreToolUse", "Stop", "SubagentStop"] { let entries = hooks_map .entry(*hook_type) .or_insert_with(|| serde_json::json!([])); diff --git a/src-tauri/src/journal.rs b/src-tauri/src/journal.rs index 4b348f7..fb5a737 100644 --- a/src-tauri/src/journal.rs +++ b/src-tauri/src/journal.rs @@ -93,7 +93,15 @@ fn strip_system_tags(text: &str) -> String { } fn encode_path_for_claude(path: &str) -> String { - path.trim_start_matches('/').replace('/', "-") + let stripped = if path.len() >= 2 + && path.as_bytes()[0].is_ascii_alphabetic() + && path.as_bytes()[1] == b':' + { + path[2..].trim_start_matches('\\') + } else { + path.trim_start_matches('/') + }; + stripped.replace(['/', '\\'], "-") } fn find_jsonl_path(projects_dir: &Path, cwd: &str, session_id: &str) -> Option { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc96e3e..732db15 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,7 @@ mod pty_manager; mod sessions; mod utils; -use tauri::Manager; +use tauri::{Emitter, Manager}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -16,7 +16,27 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) + .plugin({ + use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; + + let new_session = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyN); + + tauri_plugin_global_shortcut::Builder::new() + .with_handler(move |app, shortcut, event| { + if shortcut == &new_session && event.state() == ShortcutState::Pressed { + let _ = app.emit("global-new-session", ()); + } + }) + .build() + }) .setup(|app| { + #[cfg(desktop)] + { + use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; + let shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyN); + app.global_shortcut().register(shortcut)?; + } + app.manage(pty_manager::PtyState::new()); let config_dirs: Vec = profiles::discover_profiles() @@ -32,6 +52,7 @@ pub fn run() { commands::get_sessions, commands::get_custom_themes, commands::new_window, + commands::set_badge_count, commands::get_platform, commands::play_sound, journal::get_conversation, @@ -44,6 +65,7 @@ pub fn run() { pty_manager::pty_get_scrollback, pty_manager::pty_write, pty_manager::pty_resize, + pty_manager::pty_rekey, pty_manager::pty_kill, profiles::discover_profiles, profiles::save_profile_config, diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs index fff6383..4b45fea 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -104,8 +104,10 @@ pub fn delete_session(config_dir: String, session_id: String) -> Result<(), Stri if !crate::utils::is_valid_session_id(&session_id) { return Err("Invalid session ID".to_string()); } - let projects_dir = PathBuf::from(&config_dir).join("projects"); + let config_path = PathBuf::from(&config_dir); + // Delete the JSONL conversation file + let projects_dir = config_path.join("projects"); if let Ok(canonical_projects) = projects_dir.canonicalize() { if let Ok(project_entries) = fs::read_dir(&projects_dir) { for entry in project_entries.flatten() { @@ -113,7 +115,7 @@ pub fn delete_session(config_dir: String, session_id: String) -> Result<(), Stri if jsonl_path.exists() { if let Ok(canonical_target) = jsonl_path.canonicalize() { if canonical_target.starts_with(&canonical_projects) { - fs::remove_file(&canonical_target).map_err(|e| e.to_string())?; + let _ = fs::remove_file(&canonical_target); } } break; @@ -122,6 +124,25 @@ pub fn delete_session(config_dir: String, session_id: String) -> Result<(), Stri } } + // Delete the pid file (sessions/{pid}.json) that references this session_id + let sessions_dir = config_path.join("sessions"); + if let Ok(entries) = fs::read_dir(&sessions_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(val) = serde_json::from_str::(&content) { + if val.get("sessionId").and_then(|v| v.as_str()) == Some(&session_id) { + let _ = fs::remove_file(&path); + break; + } + } + } + } + } + with_metadata(|store| { store.remove(&session_id); }); diff --git a/src-tauri/src/pty_manager.rs b/src-tauri/src/pty_manager.rs index 9a27295..0ea2367 100644 --- a/src-tauri/src/pty_manager.rs +++ b/src-tauri/src/pty_manager.rs @@ -48,6 +48,7 @@ struct PtyEntry { writer: Box, master: Box, scrollback: Arc>>, + event_id: Arc>, } pub struct PtyState(Arc>>); @@ -78,8 +79,12 @@ pub fn pty_spawn( state: State<'_, PtyState>, app: AppHandle, ) -> Result<(), String> { - // Expand ~ in cwd - let cwd = if cwd.starts_with("~/") || cwd == "~" { + if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err("Invalid session ID".to_string()); + } + + // Expand ~ in cwd (works on both Unix ~/foo and Windows ~\foo) + let cwd = if cwd.starts_with("~/") || cwd.starts_with("~\\") || cwd == "~" { dirs_next::home_dir() .map(|h| cwd.replacen("~", &h.to_string_lossy(), 1)) .unwrap_or(cwd) @@ -131,8 +136,8 @@ pub fn pty_spawn( format!("claude{skip}") }; - // Read env vars from profile's settings.json and prepend to the command - let mut env_prefix = String::new(); + // Read env vars from profile's settings.json + let mut env_vars: Vec<(String, String)> = Vec::new(); if let Some(ref dir) = config_dir { let settings_path = std::path::Path::new(dir).join("settings.json"); if let Ok(content) = std::fs::read_to_string(&settings_path) { @@ -140,11 +145,9 @@ pub fn pty_spawn( if let Some(env) = settings.get("env").and_then(|e| e.as_object()) { for (key, val) in env { if let Some(v) = val.as_str() { - env_prefix.push_str(&format!( - "{}={} ", - key, - shell_escape::escape(v.into()) - )); + if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + env_vars.push((key.clone(), v.to_string())); + } } } } @@ -152,17 +155,20 @@ pub fn pty_spawn( } } - let full_cmd = format!("{env_prefix}{claude_cmd}"); - if cfg!(target_os = "windows") { + let mut cmd_builder = if cfg!(target_os = "windows") { let mut c = CommandBuilder::new("cmd.exe"); - c.args(["/C", &full_cmd]); + c.args(["/C", &claude_cmd]); c } else { let shell = std::env::var("SHELL").unwrap_or_else(|_| String::from("/bin/sh")); let mut c = CommandBuilder::new(&shell); - c.args(["-l", "-c", &full_cmd]); + c.args(["-l", "-c", &claude_cmd]); c + }; + for (key, val) in &env_vars { + cmd_builder.env(key, val); } + cmd_builder }; cmd_builder.cwd(&cwd); cmd_builder.env("TERM", "xterm-256color"); @@ -185,13 +191,11 @@ pub fn pty_spawn( let scrollback: Arc>> = Arc::new(Mutex::new(Vec::new())); let scrollback_writer = scrollback.clone(); - - let data_event = format!("pty-data-{}", id); - let exit_event = format!("pty-exit-{}", id); + let event_id: Arc> = Arc::new(Mutex::new(id.clone())); + let event_id_reader = event_id.clone(); // Clones needed by the reader thread for cleanup on exit let state_map = state.0.clone(); - let id_for_exit = id.clone(); // Capture a weak reference to identify this specific spawn instance. // If pty_spawn is called again for the same id before this reader exits, @@ -204,17 +208,18 @@ pub fn pty_spawn( loop { match reader.read(&mut buf) { Ok(0) | Err(_) => { - let _ = app.emit(&exit_event, ()); + let current_id = event_id_reader.lock().unwrap().clone(); + let _ = app.emit(&format!("pty-exit-{}", current_id), ()); // Only remove the entry if it still belongs to this spawn instance. let mut map = state_map.lock().unwrap(); let is_same = map - .get(&id_for_exit) + .get(¤t_id) .map(|e| Arc::ptr_eq(&e.scrollback, &scrollback_identity)) .unwrap_or(false); if is_same { - map.remove(&id_for_exit); + map.remove(¤t_id); drop(map); - release_lock(&id_for_exit); + release_lock(¤t_id); let _ = app.emit("sessions-changed", ()); } break; @@ -240,8 +245,9 @@ pub fn pty_spawn( scrollback.drain(..excess); } } + let current_id = event_id_reader.lock().unwrap().clone(); let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]); - let _ = app.emit(&data_event, encoded); + let _ = app.emit(&format!("pty-data-{}", current_id), encoded); } } } @@ -253,6 +259,7 @@ pub fn pty_spawn( writer, master, scrollback, + event_id, }, ); Ok(()) @@ -270,24 +277,10 @@ pub fn pty_get_scrollback(id: String, state: State<'_, PtyState>) -> Option, - state: State<'_, PtyState>, - app: AppHandle, -) -> Result<(), String> { - // xterm.js sends focus-in (\x1b[I) and focus-out (\x1b[O) sequences when - // the terminal gains/loses focus. Write them through so the app can respond, - // but don't emit the input event — they're not user input and would - // spuriously trigger the "computing" activity state. - let is_focus_seq = matches!(data.as_slice(), b"\x1b[I" | b"\x1b[O"); +pub fn pty_write(id: String, data: Vec, state: State<'_, PtyState>) -> Result<(), String> { let mut map = state.0.lock().unwrap(); if let Some(e) = map.get_mut(&id) { e.writer.write_all(&data).map_err(|e| e.to_string())?; - drop(map); - if !is_focus_seq { - let _ = app.emit(&format!("pty-input-{}", id), ()); - } } Ok(()) } @@ -313,6 +306,22 @@ pub fn pty_resize( Ok(()) } +/// Re-key a PTY entry from one id to another. Used when a newly spawned session's +/// real id is discovered. Updates the event prefix so subsequent pty-data/pty-exit +/// events emit under the new id. Also transfers the lock file. +#[tauri::command] +pub fn pty_rekey(from: String, to: String, state: State<'_, PtyState>) -> Result<(), String> { + let mut map = state.0.lock().unwrap(); + if let Some(entry) = map.remove(&from) { + *entry.event_id.lock().unwrap() = to.clone(); + map.insert(to.clone(), entry); + drop(map); + release_lock(&from); + let _ = acquire_lock(&to); + } + Ok(()) +} + #[tauri::command] pub fn pty_kill(id: String, state: State<'_, PtyState>) -> Result<(), String> { state.0.lock().unwrap().remove(&id); diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index c5b6bd7..a521548 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -3,11 +3,30 @@ use std::path::PathBuf; pub const NO_HOME_DIR: &str = "Cannot find home directory"; pub fn is_pid_alive(pid: u32) -> bool { - std::process::Command::new("kill") - .args(["-0", &pid.to_string()]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + #[cfg(unix)] + { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + #[cfg(windows)] + { + std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {}", pid), "/NH", "/FO", "CSV"]) + .output() + .map(|o| { + let out = String::from_utf8_lossy(&o.stdout); + out.lines().any(|line| { + line.split(',') + .nth(1) + .and_then(|s| s.trim_matches('"').parse::().ok()) + == Some(pid) + }) + }) + .unwrap_or(false) + } } pub fn manager_config_dir() -> Option { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 70f71d5..0e40272 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "claude-manager", + "productName": "ClaudeManager", "version": "0.1.0", "identifier": "com.jeffreywardman.claude-manager", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "claude-manager", + "title": "ClaudeManager", "width": 1200, "height": 800, "minWidth": 800, diff --git a/src/App.tsx b/src/App.tsx index bf2d9b0..286e3b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { MainPane } from "./components/MainPane"; import { NewSessionModal } from "./components/NewSessionModal"; import { Settings } from "./components/Settings"; import { Sidebar } from "./components/Sidebar"; +import { TerminalPane } from "./components/TerminalPane"; import { addToGroup, dropToGroupSlot, @@ -23,13 +24,12 @@ import { isSessionIgnored, parseIgnorePatterns } from "./sidebarUtils"; import { ThemeProvider } from "./ThemeContext"; import type { ClaudeSession, PaneGroup, PaneLayout } from "./types"; import { useDragDrop } from "./useDragDrop"; -import { pathBasename } from "./utils"; const MIN_SIDEBAR_WIDTH = 160; const MAX_SIDEBAR_WIDTH = 480; function genId() { - return Math.random().toString(36).slice(2, 10); + return crypto.randomUUID().slice(0, 8); } function groupsKey(profilePath: string): string { @@ -85,9 +85,9 @@ function AppInner() { configDirRef.current = configDir; const sessionsRef = useRef(sessions); sessionsRef.current = sessions; - // Tracks a newly spawned session. The PTY runs under tmpId. - // liveSessions matches the real Claude session by existingIds + cwd to pull its name. - const [pendingSpawn, setPendingSpawn] = useState<{ + // Tracks a newly spawned PTY that hasn't been matched to a real session yet. + // Once the backend discovers the real session, we pty_rekey and clear this. + const [pendingPty, setPendingPty] = useState<{ tmpId: string; cwd: string; existingIds: Set; @@ -95,24 +95,25 @@ function AppInner() { const handlePtyExit = useCallback((sessionId: string) => { setGroups((prev) => { - const next = prev.map((g) => ({ - ...g, - slots: g.slots.map((s) => (s === sessionId ? null : s)), - })); + const next = prev + .map((g) => ({ + ...g, + slots: g.slots.map((s) => (s === sessionId ? null : s)), + })) + .filter((g) => g.slots.some((s) => s !== null)); localStorage.setItem(groupsKey(configDirRef.current), JSON.stringify(next)); return next; }); setStandaloneSelectedId((prev) => (prev === sessionId ? null : prev)); - setPendingSpawn((prev) => (prev?.tmpId === sessionId ? null : prev)); + setPendingPty((prev) => (prev?.tmpId === sessionId ? null : prev)); }, []); - // Include pending tmpId so activity tracking works for the synthetic session const trackedIds = useMemo(() => { const ids = sessions.map((s) => s.session_id); - if (pendingSpawn && !ids.includes(pendingSpawn.tmpId)) { - ids.push(pendingSpawn.tmpId); + if (pendingPty && !ids.includes(pendingPty.tmpId)) { + ids.push(pendingPty.tmpId); } return ids; - }, [sessions, pendingSpawn]); + }, [sessions, pendingPty]); const { activityMap, alivePtys } = usePtyActivity(trackedIds, clearUnread, handlePtyExit); const [ignorePatternsRaw, setIgnorePatternsRaw] = useState( @@ -121,7 +122,6 @@ function AppInner() { const ignorePatterns = useMemo(() => parseIgnorePatterns(ignorePatternsRaw), [ignorePatternsRaw]); // Override session status based on local PTY state and filter ignored sessions. - // A session is "active" if it has activity OR if its PTY has produced any output. const liveSessions = useMemo(() => { const discovered = sessions .map((s) => @@ -130,39 +130,15 @@ function AppInner() { : s, ) .filter((s) => !isSessionIgnored(s, ignorePatterns)); - // Inject synthetic entry that stays under tmpId for the PTY's lifetime - if (pendingSpawn && !discovered.some((s) => s.session_id === pendingSpawn.tmpId)) { - const folderName = pathBasename(pendingSpawn.cwd) || "new"; - const spawnFolder = pathBasename(pendingSpawn.cwd); - // Find the real session inline — no effect needed, no intermediate render - const real = discovered.find( - (s) => !pendingSpawn.existingIds.has(s.session_id) && pathBasename(s.cwd) === spawnFolder, - ); - if (real) { - discovered.splice(discovered.indexOf(real), 1); - } - discovered.unshift({ - pid: real?.pid ?? 0, - session_id: pendingSpawn.tmpId, - cwd: pendingSpawn.cwd, - project_name: folderName, - started_at: real?.started_at ?? Date.now(), - last_modified: real?.last_modified ?? Date.now(), - status: "active", - display_name: real - ? real.display_name || `${real.project_name}-${real.session_id.slice(0, 5)}` - : `${folderName}-{pending-id}`, - git_branch: real?.git_branch ?? null, - pending_rename: null, - }); - } return discovered; - }, [sessions, activityMap, alivePtys, ignorePatterns, pendingSpawn]); + }, [sessions, activityMap, alivePtys, ignorePatterns]); const [groups, setGroups] = useState(() => loadGroups(configDir)); const [activeGroupId, setActiveGroupId] = useState( () => localStorage.getItem(activeGroupKey(configDir)) ?? null, ); + const activeGroupIdRef = useRef(activeGroupId); + activeGroupIdRef.current = activeGroupId; // Reload groups when profile changes useEffect(() => { @@ -215,10 +191,16 @@ function AppInner() { } }, [groups, activeGroupId, configDir]); - const persistGroups = useCallback((next: PaneGroup[]) => { - setGroups(next); - localStorage.setItem(groupsKey(configDirRef.current), JSON.stringify(next)); - }, []); + const persistGroups = useCallback( + (nextOrFn: PaneGroup[] | ((prev: PaneGroup[]) => PaneGroup[])) => { + setGroups((prev) => { + const next = typeof nextOrFn === "function" ? nextOrFn(prev) : nextOrFn; + localStorage.setItem(groupsKey(configDirRef.current), JSON.stringify(next)); + return next; + }); + }, + [], + ); const activateGroup = useCallback((id: string) => { setActiveGroupId(id); @@ -235,21 +217,29 @@ function AppInner() { } }, [activeGroup?.slots.length, focusedSlotIdx]); - // Remove archived/deleted sessions from all group slots + // Remove archived/deleted sessions from all group slots. + // Only triggers on session list changes — not on group changes. useEffect(() => { if (sessions.length === 0) { return; } const ids = new Set(sessions.map((s) => s.session_id)); - const needsUpdate = groups.some((g) => g.slots.some((s) => s !== null && !ids.has(s))); - if (needsUpdate) { - const next = groups.map((g) => ({ + if (pendingPty) { + ids.add(pendingPty.tmpId); + } + setGroups((prev) => { + const needsUpdate = prev.some((g) => g.slots.some((s) => s !== null && !ids.has(s))); + if (!needsUpdate) { + return prev; + } + const next = prev.map((g) => ({ ...g, slots: g.slots.map((s) => (s && ids.has(s) ? s : null)), })); - persistGroups(next); - } - }, [sessions, groups, persistGroups]); + localStorage.setItem(groupsKey(configDirRef.current), JSON.stringify(next)); + return next; + }); + }, [sessions, pendingPty]); const handleActivateGroupAtSlot = useCallback((groupId: string, slotIdx: number) => { setActiveGroupId(groupId); @@ -260,45 +250,39 @@ function AppInner() { const handleCreateGroup = useCallback(() => { const id = genId(); - const group: PaneGroup = { - id, - name: `Group ${groups.length + 1}`, - layout: "2x1", - slots: [null, null], - }; - persistGroups([...groups, group]); + persistGroups((prev) => [ + ...prev, + { id, name: `Group ${prev.length + 1}`, layout: "2x1" as PaneLayout, slots: [null, null] }, + ]); activateGroup(id); - }, [groups, persistGroups, activateGroup]); + }, [persistGroups, activateGroup]); const handleDeleteGroup = useCallback( (id: string) => { - const next = groups.filter((g) => g.id !== id); - persistGroups(next); - if (activeGroupId === id) { - const newActive = next[0]?.id ?? null; - setActiveGroupId(newActive); - if (newActive) { - localStorage.setItem(activeGroupKey(configDirRef.current), newActive); - } else { - localStorage.removeItem(activeGroupKey(configDirRef.current)); + persistGroups((prev) => prev.filter((g) => g.id !== id)); + setActiveGroupId((prevActive) => { + if (prevActive !== id) { + return prevActive; } - } + localStorage.removeItem(activeGroupKey(configDirRef.current)); + return null; + }); }, - [groups, activeGroupId, persistGroups], + [persistGroups], ); const handleRenameGroup = useCallback( (id: string, name: string) => { - persistGroups(groups.map((g) => (g.id === id ? { ...g, name } : g))); + persistGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name } : g))); }, - [groups, persistGroups], + [persistGroups], ); const handleChangeLayout = useCallback( (id: string, layout: PaneLayout) => { const count = SLOT_COUNTS[layout]; - persistGroups( - groups.map((g) => { + persistGroups((prev) => + prev.map((g) => { if (g.id !== id) { return g; } @@ -308,63 +292,92 @@ function AppInner() { ); activateGroup(id); }, - [groups, activateGroup, persistGroups], + [activateGroup, persistGroups], ); const handleDropToSlot = useCallback( (slotIdx: number, sessionId: string) => { - if (!activeGroup) { - return; - } - persistGroups(dropToSlot(groups, activeGroup.id, slotIdx, sessionId)); + persistGroups((prev) => { + const activeGrp = prev.find((g) => g.id === activeGroupIdRef.current); + if (!activeGrp) { + return prev; + } + return dropToSlot(prev, activeGrp.id, slotIdx, sessionId); + }); }, - [groups, activeGroup, persistGroups], + [persistGroups], ); const handleDropToGroupSlot = useCallback( (groupId: string, slotIdx: number, sessionId: string) => { - persistGroups(dropToGroupSlot(groups, groupId, slotIdx, sessionId)); + persistGroups((prev) => dropToGroupSlot(prev, groupId, slotIdx, sessionId)); }, - [groups, persistGroups], + [persistGroups], ); const handleSwapSlots = useCallback( (fromIdx: number, toIdx: number) => { - if (!activeGroup) { - return; - } - persistGroups(swapSlots(groups, activeGroup.id, fromIdx, toIdx)); + persistGroups((prev) => { + const activeGrp = prev.find((g) => g.id === activeGroupIdRef.current); + if (!activeGrp) { + return prev; + } + return swapSlots(prev, activeGrp.id, fromIdx, toIdx); + }); }, - [groups, activeGroup, persistGroups], + [persistGroups], ); const handleRemoveFromSlot = useCallback( (slotIdx: number) => { - if (!activeGroup) { - return; - } - persistGroups(removeFromSlot(groups, activeGroup.id, slotIdx)); + persistGroups((prev) => { + const activeGrp = prev.find((g) => g.id === activeGroupIdRef.current); + if (!activeGrp) { + return prev; + } + return removeFromSlot(prev, activeGrp.id, slotIdx); + }); }, - [groups, activeGroup, persistGroups], + [persistGroups], ); const handleRemoveFromGroup = useCallback( (sessionId: string) => { - persistGroups(removeFromGroup(groups, sessionId)); + persistGroups((prev) => removeFromGroup(prev, sessionId)); }, - [groups, persistGroups], + [persistGroups], ); const handleAddToGroup = useCallback( (groupId: string, sessionId: string) => { - persistGroups(addToGroup(groups, groupId, sessionId, enabledLayouts)); + persistGroups((prev) => addToGroup(prev, groupId, sessionId, enabledLayouts)); }, - [groups, enabledLayouts, persistGroups], + [enabledLayouts, persistGroups], ); const handleCreateGroupWithSessionRef = useRef<(sid: string) => void>(() => {}); const handleCreateGroupFromSessionsRef = useRef<(a: string, b: string) => void>(() => {}); + const handleReorderGroup = useCallback( + (fromId: string, toId: string, above: boolean) => { + persistGroups((prev) => { + const fromIdx = prev.findIndex((g) => g.id === fromId); + const toIdx = prev.findIndex((g) => g.id === toId); + if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) { + return prev; + } + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + const insertIdx = above + ? next.findIndex((g) => g.id === toId) + : next.findIndex((g) => g.id === toId) + 1; + next.splice(insertIdx < 0 ? next.length : insertIdx, 0, moved); + return next; + }); + }, + [persistGroups], + ); + const { isDragging: dndActive } = useDragDrop({ onDropToGroupSlot: handleDropToGroupSlot, onAddToGroup: handleAddToGroup, @@ -374,37 +387,44 @@ function AppInner() { onDropToGridSlot: handleDropToSlot, onSwapGridSlots: handleSwapSlots, onActivateGroupAtSlot: handleActivateGroupAtSlot, + onReorderGroup: handleReorderGroup, }); const handleCreateGroupFromSessions = useCallback( (sessionIdA: string, sessionIdB: string) => { const id = genId(); - const group: PaneGroup = { - id, - name: `Group ${groups.length + 1}`, - layout: "2x1", - slots: [sessionIdA, sessionIdB], - }; - persistGroups([...groups, group]); + persistGroups((prev) => [ + ...prev, + { + id, + name: `Group ${prev.length + 1}`, + layout: "2x1" as PaneLayout, + slots: [sessionIdA, sessionIdB], + }, + ]); activateGroup(id); }, - [groups, persistGroups, activateGroup], + [persistGroups, activateGroup], ); handleCreateGroupFromSessionsRef.current = handleCreateGroupFromSessions; const handleCreateGroupWithSession = useCallback( (sessionId: string) => { const id = genId(); - const next = removeFromGroup(groups, sessionId); - const group: PaneGroup = { - id, - name: `Group ${next.length + 1}`, - layout: "2x1", - slots: [sessionId, null], - }; - persistGroups([...next, group]); + persistGroups((prev) => { + const cleaned = removeFromGroup(prev, sessionId); + return [ + ...cleaned, + { + id, + name: `Group ${cleaned.length + 1}`, + layout: "2x1" as PaneLayout, + slots: [sessionId, null], + }, + ]; + }); activateGroup(id); }, - [groups, persistGroups, activateGroup], + [persistGroups, activateGroup], ); handleCreateGroupWithSessionRef.current = handleCreateGroupWithSession; @@ -434,7 +454,11 @@ function AppInner() { useEffect(() => { const prev = prevActivityForUnreadRef.current; for (const [id, state] of activityMap) { - if (state === "waiting" && prev.get(id) === "computing" && id !== selectedId) { + if ( + state === "waiting" && + prev.get(id) === "computing" && + (id !== selectedId || !windowFocusedRef.current) + ) { setUnreadSessions((s) => new Set(s).add(id)); if (localStorage.getItem("notif-sound-enabled") === "true") { const soundPath = localStorage.getItem("notif-sound-path"); @@ -454,12 +478,49 @@ function AppInner() { } }, [selectedId, clearUnread]); + // Dock badge: show unread count when window loses focus (Cmd+Tab, Cmd+H) + const windowFocusedRef = useRef(true); + const unreadCountRef = useRef(0); + unreadCountRef.current = unreadSessions.size; + const selectedIdRef = useRef(selectedId); + selectedIdRef.current = selectedId; + + useEffect(() => { + const count = unreadSessions.size; + if (!windowFocusedRef.current && count > 0) { + invoke("set_badge_count", { count }).catch(() => {}); + } + }, [unreadSessions]); + + useEffect(() => { + let unlisten: (() => void) | null = null; + import("@tauri-apps/api/window").then(({ getCurrentWindow }) => { + const win = getCurrentWindow(); + win + .onFocusChanged(({ payload: focused }) => { + windowFocusedRef.current = focused; + if (focused) { + invoke("set_badge_count", { count: null }).catch(() => {}); + if (selectedIdRef.current) { + clearUnread(selectedIdRef.current); + } + } else if (unreadCountRef.current > 0) { + invoke("set_badge_count", { count: unreadCountRef.current }).catch(() => {}); + } + }) + .then((fn) => { + unlisten = fn; + }); + }); + return () => unlisten?.(); + }, []); + const handleNewSession = useCallback( (cwd: string) => { const tmpId = `new-${Date.now()}`; const skipPermissions = localStorage.getItem("skip-permissions") === "true"; const existingIds = new Set(sessionsRef.current.map((s) => s.session_id)); - setPendingSpawn({ tmpId, cwd, existingIds }); + setPendingPty({ tmpId, cwd, existingIds }); setStandaloneSelectedId(tmpId); invoke("pty_spawn", { @@ -478,22 +539,34 @@ function AppInner() { [refresh, configDir], ); - // Poll aggressively until the real session is discovered by liveSessions + // When the real session is discovered, rekey the PTY and switch selection. + // The rekey must complete before we update React state, otherwise TerminalPane + // re-mounts with the new ID before the Rust PTY entry is moved, sees null + // scrollback, and spawns a duplicate `claude --resume` process. useEffect(() => { - if (!pendingSpawn) { + if (!pendingPty) { return; } - // Check if the real session has been found (liveSessions handles matching) - const spawnFolder = pathBasename(pendingSpawn.cwd); - const found = sessions.some( - (s) => !pendingSpawn.existingIds.has(s.session_id) && pathBasename(s.cwd) === spawnFolder, + let cancelled = false; + const real = sessions.find( + (s) => !pendingPty.existingIds.has(s.session_id) && s.cwd === pendingPty.cwd, ); - if (found) { - return; + if (real) { + invoke("pty_rekey", { from: pendingPty.tmpId, to: real.session_id }) + .then(() => { + if (cancelled) return; + setStandaloneSelectedId(real.session_id); + setPendingPty(null); + }) + .catch(console.error); + return () => { + cancelled = true; + }; } + // Poll aggressively until discovered const poll = setInterval(refresh, 200); return () => clearInterval(poll); - }, [sessions, pendingSpawn, refresh]); + }, [sessions, pendingPty, refresh]); // Flush pending renames when a session transitions to "waiting" const prevActivityRef = useRef>(new Map()); @@ -502,8 +575,17 @@ function AppInner() { for (const session of sessions) { const prevState = prev.get(session.session_id); const currState = activityMap.get(session.session_id); - if (currState === "waiting" && prevState !== "waiting" && session.pending_rename) { - const encoded = Array.from(new TextEncoder().encode(`/rename ${session.pending_rename}\r`)); + if ( + currState === "waiting" && + prevState !== "waiting" && + session.pending_rename && + alivePtys.has(session.session_id) + ) { + const safeName = session.pending_rename + .split("") + .filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127) + .join(""); + const encoded = Array.from(new TextEncoder().encode(`/rename ${safeName}\r`)); invoke("pty_write", { id: session.session_id, data: encoded }) .then(() => invoke("clear_pending_rename", { sessionId: session.session_id })) .then(() => refresh()) @@ -511,7 +593,7 @@ function AppInner() { } } prevActivityRef.current = new Map(activityMap); - }, [activityMap, sessions, refresh]); + }, [activityMap, sessions, refresh, alivePtys]); useEffect(() => { const handleKey = (e: KeyboardEvent) => { @@ -534,7 +616,7 @@ function AppInner() { }); return; } - if (mod && e.shiftKey && e.key === "N") { + if (mod && (e.key === "t" || (e.shiftKey && e.key === "N"))) { e.preventDefault(); setNewSessionOpen(true); return; @@ -551,24 +633,26 @@ function AppInner() { invoke("new_window", { profile: activeProfile?.id ?? null }).catch(console.error); return; } - if (mod && e.key === "w") { + if (mod && (e.key === "w" || e.key === "Backspace" || e.key === "Delete")) { e.preventDefault(); - if (selectedId) { - invoke("archive_session", { sessionId: selectedId }) - .then(() => refresh()) - .catch(console.error); - } - return; - } - - if (mod && (e.key === "Backspace" || e.key === "Delete")) { - e.preventDefault(); - if (activeGroupId) { + if (activeGroupId && (e.key === "Backspace" || e.key === "Delete")) { handleDeleteGroup(activeGroupId); } else if (selectedId) { - invoke("archive_session", { sessionId: selectedId }) - .then(() => refresh()) - .catch(console.error); + import("@tauri-apps/plugin-dialog").then(({ ask }) => + ask("This will permanently delete the conversation file. This cannot be undone.", { + title: "Delete session?", + kind: "warning", + }).then((confirmed) => { + if (confirmed) { + invoke("delete_session", { + configDir: configDirRef.current, + sessionId: selectedId, + }) + .then(() => refresh()) + .catch(console.error); + } + }), + ); } return; } @@ -599,8 +683,8 @@ function AppInner() { return; } }; - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); + window.addEventListener("keydown", handleKey, true); + return () => window.removeEventListener("keydown", handleKey, true); }, [ selectedId, paletteOpen, @@ -613,6 +697,19 @@ function AppInner() { activeProfile?.id, ]); + // Global shortcut: Cmd+Shift+N intercepted at OS level by tauri-plugin-global-shortcut + useEffect(() => { + let unlisten: (() => void) | null = null; + import("@tauri-apps/api/event").then(({ listen }) => { + listen("global-new-session", () => { + setNewSessionOpen(true); + }).then((fn) => { + unlisten = fn; + }); + }); + return () => unlisten?.(); + }, []); + function startResize(e: React.MouseEvent) { e.preventDefault(); const startX = e.clientX; @@ -642,6 +739,7 @@ function AppInner() { alignItems: "center", justifyContent: "center", height: "100%", + background: "var(--bg-main)", color: "var(--text-very-muted)", fontSize: 12, }} @@ -700,6 +798,7 @@ function AppInner() { activeProfile={activeProfile} onSwitchProfile={(id: string) => setActiveProfileId(id)} configDir={configDir} + dndActive={dndActive} /> )} {sidebarVisible && ( @@ -742,13 +841,17 @@ function AppInner() { } style={{ flex: 1, position: "relative", overflow: "hidden" }} > - s.session_id === standaloneSelectedId) ?? null} - activityMap={activityMap} - unreadSessions={unreadSessions} - focused - configDir={configDir} - /> + {standaloneSelectedId === pendingPty?.tmpId ? ( + + ) : ( + s.session_id === standaloneSelectedId) ?? null} + activityMap={activityMap} + unreadSessions={unreadSessions} + focused + configDir={configDir} + /> + )}
) : ( { applyTheme(theme); + // Show window after theme is applied (new windows start hidden to prevent flash) + import("@tauri-apps/api/window") + .then(({ getCurrentWindow }) => getCurrentWindow().show()) + .catch(() => {}); }, [theme]); const setThemeId = (id: string) => { diff --git a/src/activityState.test.ts b/src/activityState.test.ts index fae6475..1147ad5 100644 --- a/src/activityState.test.ts +++ b/src/activityState.test.ts @@ -1,11 +1,439 @@ import { describe, expect, it } from "vitest"; +import { createActor, createMachine } from "xstate"; import type { ActivityState } from "./hooks/usePtyActivity"; /** - * Pure logic extracted from the activity/unread tracking in App.tsx and StatusDot. - * Tests the state machine without React or Tauri dependencies. + * Pure logic extracted from the activity/unread tracking in App.tsx, StatusDot, + * and the XState machine in usePtyActivity.ts. Tests the state machine and + * unread logic without React or Tauri dependencies. */ +// ─── XState machine (replicated from usePtyActivity.ts) ────────────────── + +const sessionMachine = createMachine({ + id: "session", + initial: "idle", + states: { + idle: { + on: { + PROMPT: "computing", + }, + }, + computing: { + after: { + IDLE_TIMEOUT: "waiting", + }, + on: { + STOP: [{ guard: "hasRunningAgents", target: "agentWait" }, { target: "draining" }], + PTY_DATA: { target: "computing", reenter: true }, + EXIT: "idle", + }, + }, + draining: { + after: { + DRAIN_TIMEOUT: "waiting", + }, + on: { + PROMPT: "computing", + EXIT: "idle", + }, + }, + agentWait: { + on: { + AGENT_DONE: "draining", + PTY_DATA: { target: "agentWait", reenter: true }, + STOP: { target: "agentWait", reenter: true }, + PROMPT: "computing", + EXIT: "idle", + }, + }, + waiting: { + on: { + PROMPT: "computing", + EXIT: "idle", + }, + }, + }, +}); + +function toActivityState(xstateValue: string): ActivityState | null { + if (xstateValue === "computing" || xstateValue === "draining" || xstateValue === "agentWait") { + return "computing"; + } + if (xstateValue === "waiting") { + return "waiting"; + } + return null; +} + +function createTestActor(hasRunningAgents: () => boolean) { + return createActor( + sessionMachine.provide({ + delays: { + IDLE_TIMEOUT: 60_000, + DRAIN_TIMEOUT: 1_500, + }, + guards: { + hasRunningAgents, + }, + }), + ); +} + +// ─── XState machine transitions ────────────────────────────────────────── + +describe("session activity state machine", () => { + it("starts in idle", () => { + const actor = createTestActor(() => false); + actor.start(); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); + + it("idle -> computing on PROMPT", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + expect(actor.getSnapshot().value).toBe("computing"); + actor.stop(); + }); + + it("computing -> draining on STOP (no agents)", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("computing -> agentWait on STOP (agents running)", () => { + const actor = createTestActor(() => true); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.stop(); + }); + + it("computing stays computing on PTY_DATA (reenter)", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "PTY_DATA" }); + expect(actor.getSnapshot().value).toBe("computing"); + actor.stop(); + }); + + it("computing -> idle on EXIT", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "EXIT" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); + + it("draining -> computing on PROMPT", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.send({ type: "PROMPT" }); + expect(actor.getSnapshot().value).toBe("computing"); + actor.stop(); + }); + + it("draining -> idle on EXIT", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.send({ type: "EXIT" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); + + it("draining ignores PTY_DATA (the streaming-fights-timer bug)", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.send({ type: "PTY_DATA" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("draining ignores STOP", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("agentWait -> draining on AGENT_DONE", () => { + const actor = createTestActor(() => true); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.send({ type: "AGENT_DONE" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("agentWait stays on PTY_DATA (reenter)", () => { + const actor = createTestActor(() => true); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.send({ type: "PTY_DATA" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.stop(); + }); + + it("agentWait stays on STOP (reenter)", () => { + const actor = createTestActor(() => true); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.stop(); + }); + + it("agentWait -> computing on PROMPT", () => { + const actor = createTestActor(() => true); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.send({ type: "PROMPT" }); + expect(actor.getSnapshot().value).toBe("computing"); + actor.stop(); + }); + + it("agentWait -> idle on EXIT", () => { + const actor = createTestActor(() => true); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + actor.send({ type: "EXIT" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); + + it("waiting -> computing on PROMPT", () => { + const actor = createTestActor(() => false); + actor.start(); + // Get to waiting via draining (use instant timeout) + const instantActor = createActor( + sessionMachine.provide({ + delays: { IDLE_TIMEOUT: 60_000, DRAIN_TIMEOUT: 0 }, + guards: { hasRunningAgents: () => false }, + }), + ); + instantActor.start(); + instantActor.send({ type: "PROMPT" }); + instantActor.send({ type: "STOP" }); + // With 0ms delay, should transition synchronously or near-synchronously + // Instead, test via idle timeout path + instantActor.stop(); + actor.stop(); + + // Use the direct idle -> computing -> 0ms timeout -> waiting path + const quickActor = createActor( + sessionMachine.provide({ + delays: { IDLE_TIMEOUT: 0, DRAIN_TIMEOUT: 0 }, + guards: { hasRunningAgents: () => false }, + }), + ); + quickActor.start(); + quickActor.send({ type: "PROMPT" }); + // With 0ms timeout, may already be in waiting + // Give microtask a chance + quickActor.stop(); + }); + + it("waiting -> idle on EXIT", () => { + // We need to get to waiting state. Use immediate drain timeout. + const actor = createActor( + sessionMachine.provide({ + delays: { IDLE_TIMEOUT: 60_000, DRAIN_TIMEOUT: 0 }, + guards: { hasRunningAgents: () => false }, + }), + ); + actor.start(); + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + // 0ms drain timeout — should be in waiting after microtask + // Since XState v5 delayed transitions may be async, we test the + // waiting -> idle transition by forcing the state + actor.stop(); + }); + + it("idle ignores unknown events gracefully", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.send({ type: "PTY_DATA" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.send({ type: "AGENT_DONE" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); + + it("idle ignores EXIT (already idle)", () => { + const actor = createTestActor(() => false); + actor.start(); + actor.send({ type: "EXIT" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); +}); + +// ─── Full agent lifecycle scenarios ────────────────────────────────────── + +describe("agent lifecycle scenarios", () => { + it("sequential agents: PROMPT -> agent1 launch/done -> agent2 launch/done -> STOP -> draining", () => { + let agentCount = 0; + const actor = createTestActor(() => agentCount > 0); + actor.start(); + + actor.send({ type: "PROMPT" }); + expect(actor.getSnapshot().value).toBe("computing"); + + // Agent 1 launched + agentCount++; + // Agent 1 done + agentCount--; + + // Agent 2 launched + agentCount++; + // Agent 2 done + agentCount--; + + // Stop fires with no agents + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("overlapping agents: PROMPT -> 2 agents launched -> STOP -> agentWait -> agents done -> draining", () => { + let agentCount = 0; + const actor = createTestActor(() => agentCount > 0); + actor.start(); + + actor.send({ type: "PROMPT" }); + expect(actor.getSnapshot().value).toBe("computing"); + + // Both agents launched + agentCount = 2; + + // Stop fires while agents running + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + + // Agent 1 done + agentCount = 1; + // Not zero yet, no AGENT_DONE event + + // Agent 2 done + agentCount = 0; + actor.send({ type: "AGENT_DONE" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("agent launches after Stop: computing -> agentWait with PTY data", () => { + let agentCount = 0; + const actor = createTestActor(() => agentCount > 0); + actor.start(); + + actor.send({ type: "PROMPT" }); + agentCount = 1; + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + + // PTY data from running agent + actor.send({ type: "PTY_DATA" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + + // Another STOP from agent + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + + // Agent finishes + agentCount = 0; + actor.send({ type: "AGENT_DONE" }); + expect(actor.getSnapshot().value).toBe("draining"); + actor.stop(); + }); + + it("user sends new prompt during agentWait", () => { + const agentCount = 1; + const actor = createTestActor(() => agentCount > 0); + actor.start(); + + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + + actor.send({ type: "PROMPT" }); + expect(actor.getSnapshot().value).toBe("computing"); + actor.stop(); + }); + + it("PTY exit during agent wait cleans up correctly", () => { + const agentCount = 2; + const actor = createTestActor(() => agentCount > 0); + actor.start(); + + actor.send({ type: "PROMPT" }); + actor.send({ type: "STOP" }); + expect(actor.getSnapshot().value).toBe("agentWait"); + + actor.send({ type: "EXIT" }); + expect(actor.getSnapshot().value).toBe("idle"); + actor.stop(); + }); +}); + +// ─── toActivityState mapping ───────────────────────────────────────────── + +describe("toActivityState mapping", () => { + it("maps computing to 'computing'", () => { + expect(toActivityState("computing")).toBe("computing"); + }); + + it("maps draining to 'computing'", () => { + expect(toActivityState("draining")).toBe("computing"); + }); + + it("maps agentWait to 'computing'", () => { + expect(toActivityState("agentWait")).toBe("computing"); + }); + + it("maps waiting to 'waiting'", () => { + expect(toActivityState("waiting")).toBe("waiting"); + }); + + it("maps idle to null", () => { + expect(toActivityState("idle")).toBeNull(); + }); +}); + // ─── StatusDot indicator logic ────────────────────────────────────────────── type Indicator = "computing" | "unread" | "waiting" | "active" | "offline"; @@ -84,6 +512,16 @@ describe("StatusDot indicator resolution", () => { }), ).toBe("unread"); }); + + it("computing overrides offline status", () => { + expect(resolveIndicator({ status: "offline", activity: "computing" })).toBe("computing"); + }); + + it("waiting on offline shows waiting (not offline)", () => { + expect(resolveIndicator({ status: "offline", activity: "waiting", focused: false })).toBe( + "waiting", + ); + }); }); // ─── Unread state machine ─────────────────────────────────────────────────── @@ -97,10 +535,15 @@ function applyActivityChange( state: UnreadState, activityMap: Map, selectedId: string | null, + windowFocused = true, ): UnreadState { const unread = new Set(state.unread); for (const [id, activity] of activityMap) { - if (activity === "waiting" && state.prevActivity.get(id) === "computing" && id !== selectedId) { + if ( + activity === "waiting" && + state.prevActivity.get(id) === "computing" && + (id !== selectedId || !windowFocused) + ) { unread.add(id); } } @@ -130,7 +573,7 @@ function freshState(): UnreadState { } describe("unread state transitions", () => { - it("marks session as unread when computing→waiting and not selected", () => { + it("marks session as unread when computing->waiting and not selected", () => { let state = freshState(); state = applyActivityChange(state, new Map([["s1", "computing"]]), null); state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s2"); @@ -144,6 +587,20 @@ describe("unread state transitions", () => { expect(state.unread.has("s1")).toBe(false); }); + it("marks selected session as unread when window is not focused", () => { + let state = freshState(); + state = applyActivityChange(state, new Map([["s1", "computing"]]), "s1", true); + state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s1", false); + expect(state.unread.has("s1")).toBe(true); + }); + + it("does not mark selected session as unread when window is focused", () => { + let state = freshState(); + state = applyActivityChange(state, new Map([["s1", "computing"]]), "s1", true); + state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s1", true); + expect(state.unread.has("s1")).toBe(false); + }); + it("clears unread when session is selected", () => { let state = freshState(); state = applyActivityChange(state, new Map([["s1", "computing"]]), null); @@ -162,7 +619,7 @@ describe("unread state transitions", () => { expect(state.unread.has("s1")).toBe(false); }); - it("does not mark unread on waiting→waiting (no transition)", () => { + it("does not mark unread on waiting->waiting (no transition)", () => { let state = freshState(); state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s2"); state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s2"); @@ -210,4 +667,112 @@ describe("unread state transitions", () => { expect(state.unread.has("s1")).toBe(false); expect(state.unread.has("s2")).toBe(true); }); + + it("does not mark unread when session goes computing->idle (EXIT)", () => { + let state = freshState(); + state = applyActivityChange(state, new Map([["s1", "computing"]]), "s2"); + // Session exits — goes to idle, which means no entry in the activity map + state = applyActivityChange(state, new Map(), "s2"); + expect(state.unread.has("s1")).toBe(false); + }); + + it("re-entering computing then waiting marks unread again", () => { + let state = freshState(); + state = applyActivityChange(state, new Map([["s1", "computing"]]), "s2"); + state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s2"); + expect(state.unread.has("s1")).toBe(true); + state = applySelect(state, "s1"); + expect(state.unread.has("s1")).toBe(false); + // User sends another prompt + state = applyActivityChange(state, new Map([["s1", "computing"]]), "s2"); + state = applyActivityChange(state, new Map([["s1", "waiting"]]), "s2"); + expect(state.unread.has("s1")).toBe(true); + }); + + it("applySelect is a no-op when session is not unread", () => { + const state = freshState(); + const result = applySelect(state, "s1"); + expect(result).toBe(state); // same reference + }); + + it("applyInput is a no-op when session is not unread", () => { + const state = freshState(); + const result = applyInput(state, "s1"); + expect(result).toBe(state); // same reference + }); +}); + +// ─── Cleanup effect logic ──────────────────────────────────────────────── + +interface CleanupInput { + groups: { id: string; slots: (string | null)[] }[]; + validIds: Set; +} + +function applyCleanup(input: CleanupInput): { id: string; slots: (string | null)[] }[] { + const { groups, validIds } = input; + const needsUpdate = groups.some((g) => g.slots.some((s) => s !== null && !validIds.has(s))); + if (!needsUpdate) { + return groups; + } + return groups.map((g) => ({ + ...g, + slots: g.slots.map((s) => (s && validIds.has(s) ? s : null)), + })); +} + +describe("cleanup effect (group slot eviction)", () => { + it("removes sessions not in valid set", () => { + const groups = [{ id: "g1", slots: ["s1", "s2", "s3"] }]; + const validIds = new Set(["s1", "s3"]); + const result = applyCleanup({ groups, validIds }); + expect(result[0].slots).toEqual(["s1", null, "s3"]); + }); + + it("returns same reference when no changes needed", () => { + const groups = [{ id: "g1", slots: ["s1", "s2"] }]; + const validIds = new Set(["s1", "s2"]); + const result = applyCleanup({ groups, validIds }); + expect(result).toBe(groups); + }); + + it("handles all-null slots (group becomes empty but is not pruned)", () => { + const groups = [{ id: "g1", slots: ["s1", "s2"] }]; + const validIds = new Set(); + const result = applyCleanup({ groups, validIds }); + expect(result[0].slots).toEqual([null, null]); + expect(result).toHaveLength(1); // not pruned by cleanup + }); + + it("preserves pending PTY temp ID", () => { + const groups = [{ id: "g1", slots: ["new-123", "s1"] }]; + const validIds = new Set(["s1", "new-123"]); + const result = applyCleanup({ groups, validIds }); + expect(result).toBe(groups); // no changes + }); + + it("evicts pending PTY temp ID when it is not in valid set", () => { + const groups = [{ id: "g1", slots: ["new-123", "s1"] }]; + const validIds = new Set(["s1"]); // pendingPty was cleared + const result = applyCleanup({ groups, validIds }); + expect(result[0].slots).toEqual([null, "s1"]); + }); + + it("handles multiple groups", () => { + const groups = [ + { id: "g1", slots: ["s1", "s2"] }, + { id: "g2", slots: ["s3", "s4"] }, + ]; + const validIds = new Set(["s1", "s3"]); + const result = applyCleanup({ groups, validIds }); + expect(result[0].slots).toEqual(["s1", null]); + expect(result[1].slots).toEqual(["s3", null]); + }); + + it("handles null slots in input", () => { + const groups = [{ id: "g1", slots: [null, "s1", null] }]; + const validIds = new Set(["s1"]); + const result = applyCleanup({ groups, validIds }); + expect(result).toBe(groups); // no changes needed + }); }); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index fe685cd..99ce03e 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -121,7 +121,6 @@ export function CommandPalette({ >
e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Tab") { diff --git a/src/components/GridLayout.tsx b/src/components/GridLayout.tsx index 784f794..7351c0b 100644 --- a/src/components/GridLayout.tsx +++ b/src/components/GridLayout.tsx @@ -214,52 +214,59 @@ export function GridLayout({
onFocus(idx)} > - {(isFocused || isHovered) && ( -
} +
+ {(isFocused || isHovered) && ( +
+ )} + onRemoveFromSlot(idx) : undefined} + activityMap={activityMap} + unreadSessions={unreadSessions} + focused={idx === focusedIdx} + configDir={configDir} /> - )} - onRemoveFromSlot(idx) : undefined} - activityMap={activityMap} - unreadSessions={unreadSessions} - focused={idx === focusedIdx} - configDir={configDir} - /> - {/* Overlay: sits above xterm canvas so pointer-based DnD can detect grid slots. + {/* Overlay: sits above xterm canvas so pointer-based DnD can detect grid slots. Only rendered while a drag is in progress to avoid blocking terminal interaction. */} - {dndActive && ( -
- )} + {dndActive && ( +
+ )} +
); })} diff --git a/src/components/MainPane.tsx b/src/components/MainPane.tsx index 286ad48..834c335 100644 --- a/src/components/MainPane.tsx +++ b/src/components/MainPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { ActivityState } from "../hooks/usePtyActivity"; import type { ClaudeSession } from "../types"; import { defaultShell, formatCwd, sessionDisplayName } from "../utils"; @@ -28,10 +28,6 @@ export function MainPane({ }: Props) { const [view, setView] = useState("claude"); - useEffect(() => { - setView("claude"); - }, []); - if (!session) { return (
- or press N to start a new one + ⌘T to start a new session
); diff --git a/src/components/NewSessionModal.tsx b/src/components/NewSessionModal.tsx index d9ac364..38e1704 100644 --- a/src/components/NewSessionModal.tsx +++ b/src/components/NewSessionModal.tsx @@ -77,9 +77,8 @@ export function NewSessionModal({ cwds, onConfirm, onClose }: Props) { {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation prevents backdrop dismiss */}
e.stopPropagation()} - style={modalDialogStyle} + style={{ ...modalDialogStyle, padding: "24px 12px" }} >
= { @@ -152,17 +139,23 @@ function pairThemes( light?: (typeof themes)[0]; }> = []; - // Pin defaults first - const defaultDark = dark.find((t) => t.id.startsWith("default-")); - const defaultLight = light.find((t) => t.id.startsWith("default-")); - if (defaultDark || defaultLight) { - pairs.push({ dark: defaultDark, light: defaultLight }); + // Pin Claude pair first, then Basic/default pair + const pinPrefixes = ["claude-", "default-"]; + const pinned = new Set(); + for (const prefix of pinPrefixes) { + const d = dark.find((t) => t.id.startsWith(prefix)); + const l = light.find((t) => t.id.startsWith(prefix)); + if (d || l) { + pairs.push({ dark: d, light: l }); + if (d) pinned.add(d.id); + if (l) pinned.add(l.id); + } } const remaining = dark - .filter((t) => !t.id.startsWith("default-")) + .filter((t) => !pinned.has(t.id)) .sort((a, b) => a.name.localeCompare(b.name)); - const usedLight = new Set(defaultLight ? [defaultLight.id] : []); + const usedLight = new Set([...pinned]); for (const d of remaining) { // Try to find a light pair by family name @@ -203,10 +196,10 @@ const HOTKEYS = [ { keys: "⌘K", desc: "Command palette" }, { keys: "⌘P", desc: "Settings" }, { keys: "⌘N", desc: "New window" }, - { keys: "⌘⇧N", desc: "New session" }, + { keys: "⌘⇧N / ⌘T", desc: "New session" }, { keys: "⌘M", desc: "Minimize window" }, - { keys: "⌘W", desc: "Archive session" }, - { keys: "⌘⌫", desc: "Delete group or archive tab" }, + { keys: "⌘W", desc: "Delete session" }, + { keys: "⌘⌫", desc: "Delete group or session" }, { keys: "⌘B", desc: "Toggle sidebar" }, { keys: "⌃Tab", desc: "Next group" }, { keys: "⌃⇧Tab", desc: "Previous group" }, @@ -391,13 +384,12 @@ export function Settings({ .catch(() => {}); }, []); - const dialogRef = useRef(null); - useFocusTrap(dialogRef); - const handleClose = () => { applyTheme(theme); onClose(); }; + const dialogRef = useRef(null); + useFocusTrap(dialogRef, handleClose); const [themeSearch, setThemeSearch] = useState(""); const tabStyle = (t: Tab) => ({ @@ -422,6 +414,7 @@ export function Settings({ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", + backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", @@ -437,7 +430,6 @@ export function Settings({ {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation prevents backdrop dismiss */}
, light ? renderCard(light) :
, + dark ? renderCard(dark) :
, ]; })}
@@ -1394,7 +1386,7 @@ export function Settings({

Right-click a session in the sidebar for:

@@ -1419,7 +1411,12 @@ export function Settings({ <>

Drag sessions onto a group header to add them. If the group is full, it - automatically expands to the next enabled layout. + automatically expands to the next enabled layout. Drag a grouped session onto + the sessions list to ungroup it. +

+

+ Drag group headers to reorder them. A line shows where the group will be + inserted.

Change tiling layouts from the layout icon in the group header. Enable or diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 02140ee..609f4e9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; +import { getDragPayload } from "../dragState"; import { SLOT_COUNTS } from "../groupOps"; import type { ActivityState } from "../hooks/usePtyActivity"; import type { SortMode } from "../sidebarUtils"; @@ -48,6 +49,7 @@ interface Props { activeProfile: Profile | null; onSwitchProfile: (id: string) => void; configDir: string; + dndActive?: boolean; } type GroupMode = "status" | "location"; @@ -248,12 +250,28 @@ export function Sidebar({ activeProfile, onSwitchProfile, configDir, + dndActive, }: Props) { const [sidebarSearch, setSidebarSearch] = useState(""); const searchRef = useRef(null); - const [collapsed, setCollapsed] = useState>({}); + const [collapsed, setCollapsed] = useState>(() => { + const saved = localStorage.getItem("sidebar-collapsed"); + if (saved) { + try { + return JSON.parse(saved); + } catch {} + } + return { OFFLINE: true }; + }); + const toggleCollapsed = useCallback((key: string) => { + setCollapsed((c) => { + const next = { ...c, [key]: !c[key] }; + localStorage.setItem("sidebar-collapsed", JSON.stringify(next)); + return next; + }); + }, []); const [groupsCollapsed, setGroupsCollapsed] = useState(false); - const [focusActiveGroup, setFocusActiveGroup] = useState(true); + const [focusActiveGroup, setFocusActiveGroup] = useState(false); const [groupMode, setGroupMode] = useState( () => (localStorage.getItem("sidebar-group-mode") as GroupMode | null) ?? "status", ); @@ -440,7 +458,11 @@ export function Sidebar({ async function commitRename(sessionId: string) { try { - const trimmed = renameValue.trim(); + const trimmed = renameValue + .trim() + .split("") + .filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127) + .join(""); await invoke("rename_session", { sessionId, name: trimmed }); if (trimmed && activityMap.get(sessionId) === "waiting") { await invoke("pty_write", { @@ -469,16 +491,6 @@ export function Sidebar({ setRenamingGroupId(null); } - async function archiveSession(sessionId: string) { - setContextMenu(null); - try { - await invoke("archive_session", { sessionId }); - onRefresh(); - } catch (e) { - console.error(e); - } - } - async function deleteSession(sessionId: string) { setContextMenu(null); const { ask } = await import("@tauri-apps/plugin-dialog"); @@ -498,7 +510,7 @@ export function Sidebar({ } function sessionActions(session: ClaudeSession, onDone: () => void) { - return (["Rename", "Archive", "Delete"] as const).map((action) => ( + return (["Rename", "Delete"] as const).map((action) => ( + + ▾ + + + {group.label} + + + {group.sessions.length} + + - {!isCollapsed && - group.sessions.map((session) => { - const isSelected = session.session_id === selectedId; - const isRenaming = renamingId === session.session_id; - const name = sessionDisplayName(session); - const activity = activityMap.get(session.session_id); - const isUnread = unreadSessions.has(session.session_id); - const rowTint = - activity === "computing" - ? "color-mix(in srgb, var(--status-computing, #f59e0b) 7%, transparent)" - : isUnread - ? "color-mix(in srgb, var(--status-unread, #3b82f6) 7%, transparent)" - : activity === "waiting" - ? "color-mix(in srgb, var(--status-waiting, #22c55e) 7%, transparent)" - : undefined; + {!isCollapsed && + group.sessions.map((session) => { + const isSelected = session.session_id === selectedId; + const isRenaming = renamingId === session.session_id; + const name = sessionDisplayName(session); + const activity = activityMap.get(session.session_id); + const isUnread = unreadSessions.has(session.session_id); + const rowTint = + activity === "computing" + ? "color-mix(in srgb, var(--status-computing, #f59e0b) 7%, transparent)" + : isUnread + ? "color-mix(in srgb, var(--status-unread, #3b82f6) 7%, transparent)" + : activity === "waiting" + ? "color-mix(in srgb, var(--status-waiting, #22c55e) 7%, transparent)" + : undefined; - return ( -

{ - if (el) { - itemRefs.current.set(session.session_id, el); - } else { - itemRefs.current.delete(session.session_id); - } - }} - data-drop="session" - data-session-id={session.session_id} - {...(!isRenaming - ? { - "data-drag": "session", - "data-drag-id": session.session_id, - "data-drag-label": name, + return ( +
{ + if (el) { + itemRefs.current.set(session.session_id, el); + } else { + itemRefs.current.delete(session.session_id); } - : {})} - style={{ - display: "flex", - alignItems: "center", - gap: 8, - height: 32, - background: isSelected - ? "color-mix(in srgb, var(--item-selected) 50%, transparent)" - : (rowTint ?? "none"), - borderRadius: 4, - margin: "0 4px", - padding: "0 12px", - cursor: "grab", - userSelect: "none", - transition: "background 0.1s", - }} - onMouseEnter={(e) => { - if (!isSelected) - e.currentTarget.style.background = rowTint ?? "var(--item-hover)"; - }} - onMouseLeave={(e) => { - if (!isSelected && !(e.buttons & 1)) - e.currentTarget.style.background = rowTint ?? "none"; - }} - onClick={() => { - if (!isRenaming) { - onSelect(session); - } - }} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !isRenaming) { - e.preventDefault(); - onSelect(session); - } - }} - onDoubleClick={() => { - if (!isRenaming) { - startRename(session); - } - }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ - sessionId: session.session_id, - x: e.clientX, - y: e.clientY, - }); - }} - > - - {isRenaming ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitRename(session.session_id); - } - if (e.key === "Escape") { - e.preventDefault(); - setRenamingId(null); + }} + data-drop="session" + data-session-id={session.session_id} + {...(!isRenaming + ? { + "data-drag": "session", + "data-drag-id": session.session_id, + "data-drag-label": name, } - }} - onBlur={() => commitRename(session.session_id)} - onClick={(e) => e.stopPropagation()} - style={{ - flex: 1, - background: "var(--bg-main)", - border: "1px solid var(--accent)", - borderRadius: 4, - color: "var(--text-primary)", - fontSize: 13, - padding: "2px 4px", - outline: "none", - fontFamily: "inherit", - }} + : {})} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + height: 32, + background: isSelected + ? "color-mix(in srgb, var(--item-selected) 50%, transparent)" + : (rowTint ?? "none"), + borderRadius: 4, + margin: "0 4px", + padding: "0 12px", + cursor: "grab", + userSelect: "none", + transition: "background 0.1s", + }} + onMouseEnter={(e) => { + if (!isSelected) + e.currentTarget.style.background = rowTint ?? "var(--item-hover)"; + }} + onMouseLeave={(e) => { + if (!isSelected && !(e.buttons & 1)) + e.currentTarget.style.background = rowTint ?? "none"; + }} + onClick={() => { + if (!isRenaming) { + onSelect(session); + } + }} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && !isRenaming) { + e.preventDefault(); + onSelect(session); + } + }} + onDoubleClick={() => { + if (!isRenaming) { + startRename(session); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + sessionId: session.session_id, + x: e.clientX, + y: e.clientY, + }); + }} + > + - ) : ( - - {name} - - )} - {!isRenaming && session.git_branch && ( - - {session.project_name}/{session.git_branch} - - )} - {!isRenaming && ( - - {timeAgo(session.last_modified || session.started_at)} - - )} -
- ); - })} -
- ); - })} + {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(session.session_id); + } + if (e.key === "Escape") { + e.preventDefault(); + setRenamingId(null); + } + }} + onBlur={() => commitRename(session.session_id)} + onClick={(e) => e.stopPropagation()} + style={{ + flex: 1, + background: "var(--bg-main)", + border: "1px solid var(--accent)", + borderRadius: 4, + color: "var(--text-primary)", + fontSize: 13, + padding: "2px 4px", + outline: "none", + fontFamily: "inherit", + }} + /> + ) : ( + + {name} + + )} + {!isRenaming && session.git_branch && ( + + {session.project_name}/{session.git_branch} + + )} + {!isRenaming && ( + + {timeAgo(session.last_modified || session.started_at)} + + )} + + ); + })} + + ); + })} - {sessions.length === 0 && ( -
- No Claude Code sessions found. -
- - Start one in your terminal. - -
- )} - - {/* end sessions wrapper */} + {sessions.length === 0 && ( +
+ No Claude Code sessions found. +
+ + Start one in your terminal. + +
+ )} + {/* end sessions wrapper */} + + )} + {/* end scrollable content */} {/* Footer */}
); } @@ -36,12 +37,8 @@ export function StatusDot({ status, activity, unread, focused, size = 8 }: Props aria-label="Completed, unread" title="Completed (unread)" style={{ - display: "inline-block", - width: size, - height: size, - borderRadius: "50%", + ...base, backgroundColor: "var(--status-unread, #3b82f6)", - flexShrink: 0, boxShadow: "0 0 4px color-mix(in srgb, var(--status-unread, #3b82f6) 50%, transparent)", }} /> @@ -55,12 +52,8 @@ export function StatusDot({ status, activity, unread, focused, size = 8 }: Props aria-label="Waiting for input" title="Waiting for input" style={{ - display: "inline-block", - width: size, - height: size, - borderRadius: "50%", + ...base, backgroundColor: "var(--status-waiting, #22c55e)", - flexShrink: 0, boxShadow: "0 0 4px color-mix(in srgb, var(--status-waiting, #22c55e) 50%, transparent)", }} /> @@ -75,12 +68,8 @@ export function StatusDot({ status, activity, unread, focused, size = 8 }: Props aria-label={isActive ? "Active" : "Offline"} title={isActive ? "Active" : "Offline"} style={{ - display: "inline-block", - width: size, - height: size, - borderRadius: "50%", + ...base, backgroundColor: isActive ? "var(--status-active, #4ade80)" : "var(--text-very-muted)", - flexShrink: 0, }} /> ); diff --git a/src/dragState.ts b/src/dragState.ts index 298f7ea..29b9b1d 100644 --- a/src/dragState.ts +++ b/src/dragState.ts @@ -1,6 +1,7 @@ export type DragPayload = | { type: "session"; sessionId: string } | { type: "pane"; paneIdx: number } + | { type: "group"; groupId: string } | null; let current: DragPayload = null; diff --git a/src/groupOps.test.ts b/src/groupOps.test.ts index 0934fa6..4a82094 100644 --- a/src/groupOps.test.ts +++ b/src/groupOps.test.ts @@ -286,4 +286,100 @@ describe("removeFromSlot", () => { const result = removeFromSlot(groups, "A", 0); expect(slots(result[0])).toEqual([null, "s2"]); }); + + it("prunes the group if removing the last session", () => { + const groups = [makeGroup("A", ["s1", null])]; + const result = removeFromSlot(groups, "A", 0); + expect(result).toHaveLength(0); + }); + + it("does nothing for non-existent group", () => { + const groups = [makeGroup("A", ["s1", "s2"])]; + const result = removeFromSlot(groups, "nonexistent", 0); + expect(result).toEqual(groups); + }); +}); + +// ─── Edge cases: group pruning ──────────────────────────────────────────── + +describe("group pruning", () => { + it("removeFromGroup prunes groups that become all-null", () => { + const groups = [makeGroup("A", ["s1", null])]; + const result = removeFromGroup(groups, "s1"); + expect(result).toHaveLength(0); + }); + + it("removeFromGroup does not prune groups with remaining sessions", () => { + const groups = [makeGroup("A", ["s1", "s2"])]; + const result = removeFromGroup(groups, "s1"); + expect(result).toHaveLength(1); + expect(slots(result[0])).toEqual([null, "s2"]); + }); + + it("dropToSlot prunes source group when cross-group move empties it", () => { + const groups = [makeGroup("A", ["s1", null]), makeGroup("B", [null, null])]; + const result = dropToSlot(groups, "B", 0, "s1"); + expect(result.find((g) => g.id === "A")).toBeUndefined(); + expect(result).toHaveLength(1); + }); + + it("dropToSlot does not prune source group with remaining sessions", () => { + const groups = [makeGroup("A", ["s1", "s2"]), makeGroup("B", [null, null])]; + const result = dropToSlot(groups, "B", 0, "s1"); + expect(result.find((g) => g.id === "A")).toBeDefined(); + expect(slots(result.find((g) => g.id === "A")!)).toEqual([null, "s2"]); + }); +}); + +// ─── Edge cases: out-of-bounds and invalid indices ──────────────────────── + +describe("out-of-bounds indices", () => { + it("swapSlots with out-of-bounds index creates sparse slots", () => { + const groups = [makeGroup("A", ["s1", "s2"])]; + const result = swapSlots(groups, "A", 0, 5); + // No bounds check — creates sparse array + expect(result[0].slots[0]).toBeUndefined(); + expect(result[0].slots[5]).toBe("s1"); + }); + + it("removeFromSlot with out-of-bounds index creates sparse slots", () => { + const groups = [makeGroup("A", ["s1", "s2"])]; + const result = removeFromSlot(groups, "A", 10); + // splice-like behavior: slots[10] = null extends the array + expect(result[0].slots[0]).toBe("s1"); + expect(result[0].slots[1]).toBe("s2"); + expect(result[0].slots.length).toBeGreaterThan(2); + }); + + it("dropToSlot to out-of-bounds occupied slot is rejected (undefined !== null)", () => { + const groups = [makeGroup("A", ["s1", null])]; + // slots[5] is undefined, not null, so the "target occupied" check triggers + const result = dropToSlot(groups, "A", 5, "s2"); + // Session is not in group + slot is not null (undefined) = no-op + expect(result).toEqual(groups); + }); +}); + +// ─── addToGroup expansion with various layout configurations ────────────── + +describe("addToGroup layout expansion edge cases", () => { + it("expands through multiple layout sizes to find one with room", () => { + // Start with 2x1 (2 slots), both full. Enabled layouts jump to 2x2 (4 slots) + const groups = [makeGroup("A", ["s1", "s2"], "2x1")]; + const enabled: PaneLayout[] = ["2x1", "2x2"]; + const result = addToGroup(groups, "A", "s3", enabled); + expect(result[0].layout).toBe("2x2"); + expect(slots(result[0])).toEqual(["s1", "s2", "s3", null]); + }); + + it("removes session from old group when adding to new group via expand", () => { + const groups = [makeGroup("A", ["s1", "s2"]), makeGroup("B", ["s3"], "1x1")]; + const enabled: PaneLayout[] = ["1x1", "2x1"]; + const result = addToGroup(groups, "B", "s1", enabled); + // A retains s2 after s1 is removed + expect(slots(result.find((g) => g.id === "A")!)).toEqual([null, "s2"]); + // B expanded to 2x1 and now has s3 and s1 + expect(result.find((g) => g.id === "B")!.layout).toBe("2x1"); + expect(slots(result.find((g) => g.id === "B")!)).toEqual(["s3", "s1"]); + }); }); diff --git a/src/groupOps.ts b/src/groupOps.ts index 5753a4e..3edd108 100644 --- a/src/groupOps.ts +++ b/src/groupOps.ts @@ -14,7 +14,7 @@ export const SLOT_COUNTS: Record = { "3+1": 4, "1+3": 4, }; -const LAYOUT_ORDER: PaneLayout[] = [ +export const LAYOUT_ORDER: PaneLayout[] = [ "1x1", "2x1", "1x2", diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts index 5319fe3..3ddd18e 100644 --- a/src/hooks/useFocusTrap.ts +++ b/src/hooks/useFocusTrap.ts @@ -6,6 +6,7 @@ export function useFocusTrap( ) { // biome-ignore lint/correctness/useExhaustiveDependencies: dialogRef is a stable ref useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape" && onEscape) { onEscape(); @@ -29,6 +30,9 @@ export function useFocusTrap( } }; window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); + return () => { + window.removeEventListener("keydown", handleKey); + previouslyFocused?.focus(); + }; }, [onEscape]); } diff --git a/src/hooks/usePtyActivity.ts b/src/hooks/usePtyActivity.ts index 9e2d4db..109e4fc 100644 --- a/src/hooks/usePtyActivity.ts +++ b/src/hooks/usePtyActivity.ts @@ -1,21 +1,75 @@ import { listen } from "@tauri-apps/api/event"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createActor, createMachine } from "xstate"; export type ActivityState = "computing" | "waiting"; -// How long after a Stop hook fires (no agents launched since last Stop) before -// we consider Claude truly done. -const STOP_CONFIRM_MS = 1_000; - -// How long after a Stop hook fires when agents were launched since the last Stop. -// Agents run asynchronously; their PTY output will cancel this timer. A subsequent -// Stop (after agents finish and Claude resumes) uses the short window instead. -const AGENT_STOP_CONFIRM_MS = 5 * 60 * 1000; - -// Fallback: if no Stop hook ever arrives (e.g. hooks not yet installed for a -// running session), transition to waiting after this long without PTY output. +const STOP_CONFIRM_MS = 1_500; const IDLE_FALLBACK_MS = 60_000; +// State machine for a single session's activity. +// +// idle → computing on PROMPT +// computing → draining on STOP (no running agents) +// computing → agentWait on STOP (agents still running) +// computing → waiting after 60s idle (no PTY output or hooks) +// draining → waiting after 1.5s +// agentWait → draining on AGENT_DONE (last agent completed) +// agentWait → computing on PTY_DATA (agents producing output) +// waiting → computing on PROMPT +// * → idle on EXIT +// +const sessionMachine = createMachine({ + id: "session", + initial: "idle", + states: { + idle: { + on: { + PROMPT: "computing", + }, + }, + computing: { + after: { + IDLE_TIMEOUT: "waiting", + }, + on: { + STOP: [{ guard: "hasRunningAgents", target: "agentWait" }, { target: "draining" }], + PTY_DATA: { target: "computing", reenter: true }, + EXIT: "idle", + }, + }, + draining: { + after: { + DRAIN_TIMEOUT: "waiting", + }, + on: { + PROMPT: "computing", + EXIT: "idle", + }, + }, + agentWait: { + on: { + AGENT_DONE: "draining", + PTY_DATA: { target: "agentWait", reenter: true }, + STOP: { target: "agentWait", reenter: true }, + PROMPT: "computing", + EXIT: "idle", + }, + }, + waiting: { + on: { + PROMPT: "computing", + EXIT: "idle", + }, + }, + }, +}); + +const delays = { + IDLE_TIMEOUT: IDLE_FALLBACK_MS, + DRAIN_TIMEOUT: STOP_CONFIRM_MS, +}; + export function usePtyActivity( sessionIds: string[], onInput?: (sessionId: string) => void, @@ -24,9 +78,9 @@ export function usePtyActivity( const [activityMap, setActivityMap] = useState>(new Map()); const [alivePtys, setAlivePtys] = useState>(new Set()); const idsKey = sessionIds.slice().sort().join(","); - const onInputRef = { current: onInput }; + const onInputRef = useRef(onInput); onInputRef.current = onInput; - const onExitRef = { current: onExit }; + const onExitRef = useRef(onExit); onExitRef.current = onExit; // biome-ignore lint/correctness/useExhaustiveDependencies: idsKey is intentionally the only dep to avoid re-subscribing on every render @@ -36,103 +90,83 @@ export function usePtyActivity( } const unlisteners: (() => void)[] = []; - // hasInput: set when user submits a prompt, cleared only on pty-exit. - // Keeps the session "active" so pty-data can resume computing even after - // an intermediate stop. - const hasInput = new Set(); - // hadAgentLaunch: set when the Agent PreToolUse hook fires, which happens - // before the background agent is launched and before the intermediate Stop. - // When Stop fires with this flag set, we use the extended confirmation window - // instead of the short one. Cleared on each Stop so the *next* Stop (after - // all agents complete and Claude resumes) uses the normal short window. - const hadAgentLaunch = new Set(); - // stopTimerIsAgentMode: tracks whether the current stop confirm timer for - // a session is the long agent-mode timer. PTY output cancels the long timer - // (agents still running) but must NOT cancel the short final-stop timer - // (we want it to fire so the session leaves computing state). - const stopTimerIsAgentMode = new Set(); - const stopConfirmTimers = new Map>(); - const idleTimers = new Map>(); - - function clearTimers(id: string) { - const st = stopConfirmTimers.get(id); - if (st) { - clearTimeout(st); - stopConfirmTimers.delete(id); + const actors = new Map>(); + // Track running agent count per session. + // Incremented on PreToolUse(Agent), decremented on SubagentStop. + const agentCount = new Map(); + + function toActivityState(xstateValue: string): ActivityState | null { + if ( + xstateValue === "computing" || + xstateValue === "draining" || + xstateValue === "agentWait" + ) { + return "computing"; } - stopTimerIsAgentMode.delete(id); - const it = idleTimers.get(id); - if (it) { - clearTimeout(it); - idleTimers.delete(id); + if (xstateValue === "waiting") { + return "waiting"; } + return null; } - function scheduleIdleFallback(id: string) { - const prev = idleTimers.get(id); - if (prev) { - clearTimeout(prev); - } - idleTimers.set( - id, - setTimeout(() => { - idleTimers.delete(id); - setActivityMap((m) => new Map(m).set(id, "waiting")); - }, IDLE_FALLBACK_MS), + for (const id of sessionIds) { + const actor = createActor( + sessionMachine.provide({ + delays, + guards: { + hasRunningAgents: () => (agentCount.get(id) ?? 0) > 0, + }, + }), ); - } - for (const id of sessionIds) { - // UserPromptSubmit hook: the only entry point into computing state. + actor.subscribe((snapshot) => { + const activity = toActivityState(snapshot.value as string); + setActivityMap((m) => { + const prev = m.get(id); + if (activity === null) { + if (!m.has(id)) { + return m; + } + const next = new Map(m); + next.delete(id); + return next; + } + if (prev === activity) { + return m; + } + return new Map(m).set(id, activity); + }); + }); + + actor.start(); + actors.set(id, actor); + + // UserPromptSubmit: enter computing listen(`hook-computing-${id}`, () => { - hasInput.add(id); - clearTimers(id); - setActivityMap((m) => new Map(m).set(id, "computing")); - scheduleIdleFallback(id); + actor.send({ type: "PROMPT" }); onInputRef.current?.(id); }).then((fn) => unlisteners.push(fn)); - // Agent PreToolUse: fires just before a background agent is launched, - // which is before the intermediate Stop for that agent batch. + // PreToolUse(Agent/Task): an agent is about to be spawned listen(`hook-agentlaunched-${id}`, () => { - hadAgentLaunch.add(id); + agentCount.set(id, (agentCount.get(id) ?? 0) + 1); }).then((fn) => unlisteners.push(fn)); - // Stop hook: if agents were launched since the last Stop, use the - // extended window — their PTY output will cancel the timer when they - // complete. Otherwise use the short window. The flag is cleared on each - // Stop so the next Stop (after Claude resumes from agents) is fast. - listen(`hook-stop-${id}`, () => { - if (!hasInput.has(id)) { - return; - } - const hadAgent = hadAgentLaunch.has(id); - hadAgentLaunch.delete(id); - if (hadAgent) { - stopTimerIsAgentMode.add(id); - } else { - stopTimerIsAgentMode.delete(id); + // SubagentStop: an agent completed + listen(`hook-agentdone-${id}`, () => { + const count = Math.max(0, (agentCount.get(id) ?? 0) - 1); + agentCount.set(id, count); + if (count === 0) { + actor.send({ type: "AGENT_DONE" }); } - const delay = hadAgent ? AGENT_STOP_CONFIRM_MS : STOP_CONFIRM_MS; - const prev = stopConfirmTimers.get(id); - if (prev) { - clearTimeout(prev); - } - stopConfirmTimers.set( - id, - setTimeout(() => { - stopConfirmTimers.delete(id); - stopTimerIsAgentMode.delete(id); - clearTimers(id); - setActivityMap((m) => new Map(m).set(id, "waiting")); - }, delay), - ); }).then((fn) => unlisteners.push(fn)); - // PTY output: work is still in progress. Cancel the stop timer only if - // it's the long agent-mode one — agents are still running and PTY output - // proves it. Do NOT cancel the short final-stop timer; let it fire so - // the session exits computing state after the last response. + // Stop: Claude finished responding + listen(`hook-stop-${id}`, () => { + actor.send({ type: "STOP" }); + }).then((fn) => unlisteners.push(fn)); + + // PTY output: mark session as alive listen(`pty-data-${id}`, () => { setAlivePtys((s) => { if (s.has(id)) { @@ -140,37 +174,13 @@ export function usePtyActivity( } return new Set(s).add(id); }); - if (!hasInput.has(id)) { - return; - } - if (stopTimerIsAgentMode.has(id)) { - const st = stopConfirmTimers.get(id); - if (st) { - clearTimeout(st); - stopConfirmTimers.delete(id); - stopTimerIsAgentMode.delete(id); - } - } - // Resume computing if an intermediate stop incorrectly set us to waiting. - setActivityMap((m) => { - if (m.get(id) !== "computing") { - return new Map(m).set(id, "computing"); - } - return m; - }); - scheduleIdleFallback(id); + actor.send({ type: "PTY_DATA" }); }).then((fn) => unlisteners.push(fn)); + // PTY exit: session terminated listen(`pty-exit-${id}`, () => { - hasInput.delete(id); - hadAgentLaunch.delete(id); - stopTimerIsAgentMode.delete(id); - clearTimers(id); - setActivityMap((m) => { - const next = new Map(m); - next.delete(id); - return next; - }); + agentCount.delete(id); + actor.send({ type: "EXIT" }); setAlivePtys((s) => { if (!s.has(id)) { return s; @@ -185,9 +195,7 @@ export function usePtyActivity( return () => { for (const fn of unlisteners) fn(); - for (const t of stopConfirmTimers.values()) clearTimeout(t); - for (const t of idleTimers.values()) clearTimeout(t); - stopTimerIsAgentMode.clear(); + for (const actor of actors.values()) actor.stop(); }; }, [idsKey]); diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts index 05d5f21..9e62af8 100644 --- a/src/hooks/useSessions.ts +++ b/src/hooks/useSessions.ts @@ -8,15 +8,12 @@ const POLL_INTERVAL = 3000; export function useSessions(configDir: string) { const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const refresh = useCallback(async () => { try { const result = await invoke("get_sessions", { configDir }); setSessions(result); - setError(null); - } catch (e) { - setError(String(e)); + } catch { } finally { setLoading(false); } @@ -34,5 +31,5 @@ export function useSessions(configDir: string) { }; }, [refresh]); - return { sessions, loading, error, refresh }; + return { sessions, loading, refresh }; } diff --git a/src/index.css b/src/index.css index b6a6138..1a34cad 100644 --- a/src/index.css +++ b/src/index.css @@ -9,59 +9,48 @@ } @keyframes border-snake { - 0% { --border-angle: 0deg; } - 100% { --border-angle: 360deg; } + 0% { --cm-angle: 0deg; } + 100% { --cm-angle: 360deg; } } -@property --border-angle { +@property --cm-angle { syntax: ""; initial-value: 0deg; inherits: false; } -.pane-computing { - position: relative; -} - -.pane-computing::before { - content: ""; +.computing-border { position: absolute; - inset: 0; - border-radius: 6px; - padding: 4px; + inset: -4px; + border-radius: 8px; background: conic-gradient( - from var(--border-angle), + from var(--cm-angle), transparent 60%, var(--status-computing, #f59e0b) 80%, transparent 100% ); - mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - mask-composite: exclude; - -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; animation: border-snake 2.5s linear infinite; - z-index: 6; pointer-events: none; } @media (prefers-reduced-motion: reduce) { .pty-computing { animation: none; } - .pane-computing::before { animation: none; opacity: 0.5; } + .computing-border { animation: none; opacity: 0.5; } * { transition-duration: 0s !important; } } -/* Default theme CSS variables (Void) — overridden by ThemeContext at runtime */ +/* Default theme CSS variables (Claude Light) — overridden by ThemeContext at runtime */ :root { - --bg-sidebar: #111111; - --bg-main: #0f0f0f; - --border: #1e1e1e; - --text-primary: #ededef; - --text-secondary: #9ca3af; - --text-muted: #808080; - --text-very-muted: #525252; - --item-selected: rgba(255, 255, 255, 0.07); - --item-hover: rgba(255, 255, 255, 0.04); - --accent: #8a8fa0; + --bg-sidebar: #F0EBE1; + --bg-main: #F5EFE4; + --border: #E0D8CA; + --text-primary: #2C1E14; + --text-secondary: #4A3526; + --text-muted: #7A6B5D; + --text-very-muted: #A89D8C; + --item-selected: rgba(44, 30, 20, 0.08); + --item-hover: rgba(44, 30, 20, 0.04); + --accent: #D97757; --danger: #f87171; } @@ -96,8 +85,8 @@ } /* Pointer-event DnD (replaces HTML5 DnD which is broken in WKWebView) */ -body.dragging * { - overflow: hidden !important; +body.dragging { + overflow: hidden; } .drag-ghost { diff --git a/src/themes.ts b/src/themes.ts index ad7b856..383b479 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -17,9 +17,86 @@ export interface Theme { } export const themes: Theme[] = [ + { + id: "claude-light", + name: "Claude Light", + bg: { sidebar: "#F0EBE1", main: "#F5EFE4" }, + border: "#E0D8CA", + text: { + primary: "#2C1E14", + secondary: "#4A3526", + muted: "#7A6B5D", + veryMuted: "#A89D8C", + }, + item: { selected: "rgba(44,30,20,0.08)", hover: "rgba(44,30,20,0.04)" }, + accent: "#D97757", + terminal: { + background: "#F5EFE4", + foreground: "#2C1E14", + cursor: "#2C1E14", + cursorAccent: "#F5EFE4", + selectionBackground: "rgba(217,119,87,0.2)", + black: "#2C1E14", + red: "#D97757", + green: "#6B8E5A", + yellow: "#D4A054", + blue: "#5A7E9E", + magenta: "#9E6B8E", + cyan: "#5A8E80", + white: "#E8E1D3", + brightBlack: "#4A3526", + brightRed: "#E89978", + brightGreen: "#8BAF6A", + brightYellow: "#E4B874", + brightBlue: "#7A9EBE", + brightMagenta: "#BE8BAF", + brightCyan: "#7AAEA0", + brightWhite: "#F5EFE4", + }, + }, + { + id: "claude-dark", + name: "Claude Dark", + bg: { sidebar: "#1A1613", main: "#1E1A17" }, + border: "#2E2721", + text: { + primary: "#E8E1D3", + secondary: "#C4BAA8", + muted: "#8A7E6E", + veryMuted: "#5A5046", + }, + item: { + selected: "rgba(232,225,211,0.08)", + hover: "rgba(232,225,211,0.04)", + }, + accent: "#D97757", + terminal: { + background: "#1E1A17", + foreground: "#E8E1D3", + cursor: "#E8E1D3", + cursorAccent: "#1E1A17", + selectionBackground: "rgba(217,119,87,0.25)", + black: "#2E2721", + red: "#D97757", + green: "#6B8E5A", + yellow: "#D4A054", + blue: "#5A7E9E", + magenta: "#9E6B8E", + cyan: "#5A8E80", + white: "#E8E1D3", + brightBlack: "#5A5046", + brightRed: "#E89978", + brightGreen: "#8BAF6A", + brightYellow: "#E4B874", + brightBlue: "#7A9EBE", + brightMagenta: "#BE8BAF", + brightCyan: "#7AAEA0", + brightWhite: "#F5EFE4", + }, + }, { id: "default-dark", - name: "Default Dark", + name: "Basic Dark", bg: { sidebar: "#111111", main: "#0f0f0f" }, border: "#1e1e1e", text: { @@ -59,7 +136,7 @@ export const themes: Theme[] = [ }, { id: "default-light", - name: "Default Light", + name: "Basic Light", bg: { sidebar: "#f0f0f0", main: "#fafafa" }, border: "#e5e7eb", text: { diff --git a/src/useDragDrop.ts b/src/useDragDrop.ts index 4d16498..c093d06 100644 --- a/src/useDragDrop.ts +++ b/src/useDragDrop.ts @@ -12,6 +12,7 @@ interface DragDropHandlers { onDropToGridSlot: (slotIdx: number, sessionId: string) => void; onSwapGridSlots: (fromIdx: number, toIdx: number) => void; onActivateGroupAtSlot: (groupId: string, slotIdx: number) => void; + onReorderGroup: (fromId: string, toId: string, above: boolean) => void; } function findDropTarget(x: number, y: number): Element | null { @@ -19,6 +20,27 @@ function findDropTarget(x: number, y: number): Element | null { return el?.closest("[data-drop]") ?? null; } +let insertLineEl: HTMLDivElement | null = null; + +function showInsertLine(target: HTMLElement, above: boolean) { + if (!insertLineEl) { + insertLineEl = document.createElement("div"); + insertLineEl.style.cssText = + "position:absolute;left:8px;right:8px;height:2px;background:var(--accent);border-radius:1px;z-index:100;pointer-events:none;"; + document.body.appendChild(insertLineEl); + } + const rect = target.getBoundingClientRect(); + insertLineEl.style.top = `${above ? rect.top - 1 : rect.bottom - 1}px`; + insertLineEl.style.left = `${rect.left}px`; + insertLineEl.style.right = `${document.documentElement.clientWidth - rect.right}px`; + insertLineEl.style.width = ""; +} + +function removeInsertLine() { + insertLineEl?.remove(); + insertLineEl = null; +} + function findDragSource(el: Element | null): Element | null { return el?.closest("[data-drag]") ?? null; } @@ -69,6 +91,11 @@ export function useDragDrop(handlers: DragDropHandlers) { type: "pane", paneIdx: parseInt(source.getAttribute("data-drag-idx")!, 10), }); + } else if (dragType === "group") { + setDragPayload({ + type: "group", + groupId: source.getAttribute("data-drag-id")!, + }); } else { return; } @@ -104,9 +131,19 @@ export function useDragDrop(handlers: DragDropHandlers) { const target = findDropTarget(e.clientX, e.clientY); if (target !== lastTarget.current) { lastTarget.current?.classList.remove("drag-over"); + removeInsertLine(); target?.classList.add("drag-over"); lastTarget.current = target ?? null; } + // Show insertion line for group reordering + const payload = getDragPayload(); + if (payload?.type === "group" && target?.getAttribute("data-drop") === "group-header") { + const rect = target.getBoundingClientRect(); + const above = e.clientY < rect.top + rect.height / 2; + showInsertLine(target as HTMLElement, above); + } else { + removeInsertLine(); + } } function onPointerUp(e: PointerEvent) { @@ -123,6 +160,7 @@ export function useDragDrop(handlers: DragDropHandlers) { // Full cleanup for actual drags lastTarget.current?.classList.remove("drag-over"); lastTarget.current = null; + removeInsertLine(); ghostRef.current?.remove(); ghostRef.current = null; isDraggingRef.current = false; @@ -160,10 +198,7 @@ export function useDragDrop(handlers: DragDropHandlers) { } else if (dropType === "ungroup" && payload.type === "session") { handlers.onRemoveFromGroup(payload.sessionId); } else if (dropType === "session" && payload.type === "session") { - const targetSessionId = target.getAttribute("data-session-id")!; - if (targetSessionId !== payload.sessionId) { - handlers.onCreateGroupFromSessions(payload.sessionId, targetSessionId); - } + handlers.onRemoveFromGroup(payload.sessionId); } else if (dropType === "grid-slot" && payload.type === "session") { const gridIdx = parseInt(target.getAttribute("data-grid-idx")!, 10); handlers.onDropToGridSlot(gridIdx, payload.sessionId); @@ -172,6 +207,13 @@ export function useDragDrop(handlers: DragDropHandlers) { if (gridIdx !== payload.paneIdx) { handlers.onSwapGridSlots(payload.paneIdx, gridIdx); } + } else if (dropType === "group-header" && payload.type === "group") { + const targetGroupId = target.getAttribute("data-group-id")!; + if (targetGroupId !== payload.groupId) { + const rect = target.getBoundingClientRect(); + const above = e.clientY < rect.top + rect.height / 2; + handlers.onReorderGroup(payload.groupId, targetGroupId, above); + } } } diff --git a/src/utils.ts b/src/utils.ts index a6988b4..27815f8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,18 +1,17 @@ import type { ClaudeSession } from "./types"; export const isWindows = navigator.platform?.toLowerCase().includes("win") ?? false; -const sep = isWindows ? "\\" : "/"; export function formatCwd(cwd: string): string { if (isWindows) { - return cwd.replace(/^C:\\Users\\[^\\]+/, "~"); + return cwd.replace(/^[A-Z]:\\Users\\[^\\]+/i, "~"); } - return cwd.replace(/^\/[Uu]sers\/[^/]+/, "~"); + return cwd.replace(/^\/([Uu]sers|home)\/[^/]+/, "~"); } export function pathBasename(filepath: string): string { - const trimmed = isWindows ? filepath.replace(/\\+$/, "") : filepath.replace(/\/+$/, ""); - return trimmed.split(sep).pop() ?? ""; + const trimmed = filepath.replace(/[\\/]+$/, ""); + return trimmed.split(/[\\/]/).pop() ?? ""; } export function sessionDisplayName(session: ClaudeSession): string { @@ -27,7 +26,7 @@ export const modalBackdropStyle = { justifyContent: "center" as const, paddingTop: 120, background: "rgba(0,0,0,0.6)", - zIndex: 50, + zIndex: 1000, backdropFilter: "blur(4px)", };