diff --git a/AGENTS.md b/AGENTS.md index afdb688..a05ff01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,19 @@ Update corresponding Docs in [CrewCode Docs](/docs/), and [AGENTS.md](/AGENTS.md ## Agent Notes +Mobile window tabs use the bottom workspace dock's Tabs button and an on-demand +sheet; never reserve a top tab-strip row on phones. Preserve exact tab selection, +observed activity, pinned-tab close protection, built-in/plugin new-tab actions, +and dismiss-on-selection. Desktop retains its tab strip. Mobile overlay offsets +must not reserve the former 40px strip. See `docs/mobile-responsive-pages.md`. + + +Claude effort menus include Off, Adaptive, Low, Medium, High, XHigh, and Max. +Adaptive is a Claude-only UI selection: omit SDK `effort` and `thinking` so +Claude Code retains its environment/settings/model resolution. Off explicitly +disables thinking; named levels pass through as SDK effort values. Keep the +session, crew, desktop/web, and SSH contracts aligned. See `docs/reasoning-effort.md`. + The context usage meter must prefer the active provider's reported full context window over model-catalog metadata and static limits. Codex app-server usage reports `modelContextWindow` as an effective prompt budget; when a known full @@ -374,8 +387,20 @@ Brain catalogue without replacing genuine identities the Brain already owns, onc desktop catalogue authority exists. An explicit Background Brain enable is a new owner-observed handoff: before the enable-triggered reload, seed the exact foreground desktop chat/session catalogue even when an older authority marker exists. Reconcile -the current desktop workspace registry first for matching ids/paths and preserve -genuine Brain-only/browser-created workspaces. Until that marker is present, or while +the current desktop workspace registry first for matching ids/paths. Preserve only +Brain-only/browser-created workspaces whose observed creation time is newer than the +desktop registry; never resurrect older entries missing from the current desktop list. +Treat explicit Background Brain enable as an audited owner-local grant of each exact +existing registered local project path, preserving scopes and never granting a broader +parent; standalone Brain remains explicit-root-only and SSH stays desktop-only. Attached +Electron must read the exact native registry. Brain/browser catalogue reads may expose +registered metadata outside current roots as locked rows, but every content or execution +operation still requires the existing scope, registered-workspace, and live-root gates. +Never relay device-local project icon data in the workspace catalogue. Keep individual +encrypted Hub frames within the existing 8 MiB connection-burst ceiling and preflight +Brain output so an oversized RPC result closes only its logical browser tunnel, not the +machine relay. +Until that marker is present, or while the Brain still contains transcript-derived recovered rows, Electron reseeds the exact desktop names, active selection, tab-key order, and per-tab session order; drop unmatched recovered navigation rows while preserving transcript shards, and retain genuine @@ -452,6 +477,12 @@ The composer PromptPicker has separate Prompts and Skills tabs backed by the sha On phones, Code Editor keeps the code canvas primary and opens its file tree as a dismissible right overlay. Git Sidebar must remain the same stateful surface on desktop and mobile, becoming an off-canvas panel with backdrop/close controls instead of being hidden or squeezing chat/editor content. Changes by turn is a full-screen mobile review: its catalogue stacks above the diff when open, while a targeted changed-file route keeps the catalogue closed. Keep all JS/CSS decisions aligned at `≤768px`; see `docs/mobile-responsive-pages.md`. +Mobile/Hub typography must honor the live chat `--mono-size` and CodeMirror +`--editor-size` settings. Keep the composer at `max(16px, --mono-size)` to prevent +iOS focus zoom without blocking larger user-selected text, and keep the editor size +authoritative over CodeMirror's runtime-injected theme. See +`docs/mobile-responsive-pages.md`. + The Code Editor file tree is lazy and must expose dependency, build-output, cache, and workspace-local Python environment folders on demand for local and SSH roots. Keep `.git` and `.DS_Store` hidden from direct browsing. Do not widen bounded @@ -502,7 +533,7 @@ CrewCoder `crew-tasks` activity remains provider-owned and optional. Preserve th YuHeard PTY integration must remain bundle-safe. `PtyService` receives the active YuHeard server through an injected accessor and statically imports its shell-wrapper helpers; do not use runtime relative `require('./yuheard-*')` calls from PTY code because electron-vite can move that code into a chunk without emitting the required sibling modules. CLI launch, initial TUI paint, and prompt submission are not completed turns. Codex must use only its exact `approval-requested` and `agent-turn-complete` hook events—never generic PTY idle/BEL heuristics—while output fallback detection remains available for agents without an exact hook. Suppress every YuHeard surface only when the exact completing terminal owns keyboard focus in the focused CrewCode window; a different pane must still alert. See `docs/yuheard.md`. -CrewCoder agent profiles are separate from CrewCode execution modes. Show the desktop model-row profile picker only when the installed CrewCoder provider is active; disable it during a running turn, persist the optional session-scoped `crewcoderMode`, omit `--mode` for Configured default, and pass only `general | crewcoder | plugin | extension` to `crewcoder acp --mode`. A concrete profile locks the underlying CrewCode permission policy to Build and disables Ask/Plan/Build/Full on desktop and phone; Configured default re-enables those controls. Never retain a hidden prior Ask, Plan, or Full Access policy under a concrete profile. When the concrete `crewcoder` profile is active, show the separate desktop approval picker and persist `crewcoderApprovalMode`; expose only CrewCoder's `review`, `always`, `never`, `full-access`, and `sandboxed` values, with `review` as the fail-closed default. Treat approval changes as immutable launch authority: disable them during a running turn, drop only the idle bridge, include the value in custody, and native-resume on the next prompt. Never suppresses prompts but continues to block dangerous calls; Sandboxed applies the native sandbox policy where supported; Full access bypasses CrewCoder approval requests and dangerous-command blocking, so label that risk truthfully and never imply CrewCode Build still interposes. Never route Ask/Plan/Build/Full into CrewCoder's `--mode`. The `crewcoder` profile's plan gate is CrewCoder-owned: project `crewcoder_clarify` / `crewcoder_propose_plan` into the activity overlay and send `/approve-plan` as a prompt, never as a tool-permission Allow/Deny. See `docs/crewcoder-provider.md`. +CrewCoder agent profiles are separate from CrewCode execution modes. Show the desktop model-row profile picker only when the installed CrewCoder provider is active; disable it during a running turn, persist the optional session-scoped `crewcoderMode`, omit `--mode` for Configured default, and pass only `general | crewcoder | plugin | extension` to `crewcoder acp --mode`. A concrete profile locks the underlying CrewCode permission policy to Build and disables Ask/Plan/Build/Full on desktop and phone; Configured default re-enables those controls. Never retain a hidden prior Ask, Plan, or Full Access policy under a concrete profile. When the installed CrewCoder provider is active, show the separate desktop approval picker across general, crewcoder, plugin, extension, and Configured default; persist `crewcoderApprovalMode` independently of profile changes and honor it at launch in every profile; expose only CrewCoder's `review`, `always`, `never`, `full-access`, and `sandboxed` values, with `review` as the fail-closed default. Treat approval changes as immutable launch authority: disable them during a running turn, drop only the idle bridge, include the value in custody, and native-resume on the next prompt. Never suppresses prompts but continues to block dangerous calls; Sandboxed applies the native sandbox policy where supported; Full access bypasses CrewCoder approval requests and dangerous-command blocking, so label that risk truthfully and never imply CrewCode Build still interposes. Never route Ask/Plan/Build/Full into CrewCoder's `--mode`. The `crewcoder` profile's plan gate is CrewCoder-owned: project `crewcoder_clarify` / `crewcoder_propose_plan` into the activity overlay and send `/approve-plan` as a prompt, never as a tool-permission Allow/Deny. See `docs/crewcoder-provider.md`. CrewCoder manual compaction is capability-gated. Expose the bridge `compact()` path only after observing exact `initialize._meta["crewcoder/sessionCompact"].method === "session/compact"`; older CrewCoder versions retain summary-reset. Call the advertised method only while idle and keep CrewCoder's durable session id and ACP child after success. Use the returned summary to replace CrewCode's replay shard and add the visible compact-summary card while retaining the full rich display transcript. Treat `_crewcoder/compaction_update` as authoritative, never infer a duplicate from usage, never fabricate a turn for idle progress, and do not clear usage/replay when the provider reports a skipped compact. See `docs/crewcoder-provider.md` and `docs/conversation-storage.md`. diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index 5fe762b..72ef368 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -2,7 +2,7 @@ CrewCoder is a first-class CrewCode chat and Crew provider implemented by `src/main/agents/crewcoder-bridge.ts`. CrewCode is the ACP **client**: it spawns -`crewcoder acp --approval review`, optionally adding a selected CrewCoder +`crewcoder acp --approval ` (defaulting to `review`), optionally adding a selected CrewCoder `--mode`, and translates newline-delimited JSON-RPC 2.0 onto the shared `AgentBridge` event stream. CrewCoder remains the ACP agent. @@ -36,8 +36,9 @@ a turn is running so authority cannot change underneath live execution. It stays absent for unavailable or inactive providers and from the phone layout, where the desktop model-row reveal itself is intentionally hidden. -When the concrete **CrewCoder** profile is selected, the row also shows a -session-scoped approval picker with all native policies: +When CrewCoder is the active installed provider, the row also shows a +session-scoped approval picker for **General**, **CrewCoder**, **Plugin**, +**Extension**, and **Configured default**, with all native policies: - **Review** (`review`) lets safe calls proceed and asks for mutations and dangerous calls. - **Always** (`always`) asks for every non-safe call. @@ -45,6 +46,7 @@ session-scoped approval picker with all native policies: - **Full access** (`full-access`) accepts calls without prompts. - **Sandboxed** (`sandboxed`) shows no prompts and runs non-dangerous calls through the sandbox policy where supported. +The selected policy survives profile changes, including Configured default. Older or invalid persisted values fail closed to Review. Changing the policy drops only the idle CrewCoder bridge and native-resumes it on the next prompt; both controls are disabled during a running turn. Full access is an explicit @@ -219,8 +221,10 @@ CrewCoder TUI, including active → pending → completed ordering. ## Mode and permission enforcement -CrewCoder always starts with `--approval review`; CrewCode applies the current -composer mode when each `session/request_permission` arrives: +CrewCoder starts with the session's selected `--approval` policy in every agent +profile, defaulting to `review`. CrewCode applies the current composer mode +when a `session/request_permission` arrives; policies that emit no request +bypass this permission overlay: | CrewCode mode | Decision | | --- | --- | diff --git a/docs/current-state.md b/docs/current-state.md index 025e0e1..af29998 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -147,7 +147,7 @@ Queued follow-up messages are also session-scoped. There is exactly ONE follow-u Composer execution modes are `ask | plan | build | full`, where `full` is displayed as **Full Access**. A mode has an optional prompt preamble (`buildModePreamble`) **plus** a real per-provider permission policy; the preamble is advisory, not enforcement. Settings customizes the shared prompt text, while `Session.modePromptsEnabled` controls injection per Solo Chat from `ChatHeader`; it defaults true, copies on duplicate, and locks after startup context is sent because provider context cannot be revoked. Disabling it must never weaken provider-native permission policy, and skills/delegation context must still be delivered. Build approvals may grant **allow all (this turn ONLY)** only through main-issued `{bridgeId, turnId}` capability state: approve the current and later permission requests in that turn, clear on every turn/bridge termination boundary, and never use provider-native remembered choices that could outlive the turn. The wire value is `'full'` (renamed from `'yolo'`), the display token is `Mode` = `'Full'`, and the label is `MODE_LABEL` = `'Full Access'`. Because the wire value changed, persisted sessions and `defaultMode` can still hold `'yolo'`, so every read of a persisted mode goes through `normalizeModeLevel()` (`yolo` -> `full`, unknown -> `build`) — an unrecognized level returns `undefined` from `MODE_FROM_SETTINGS`, which crashes the composer mode picker and prepends a literal "undefined" to the prompt. Full Access means every tool is pre-approved: Claude uses `bypassPermissions`, Codex uses `workspace-write` + `never` approvals (deliberately NOT `danger-full-access` — worktree isolation stays), pi/hermes/CrewCoder auto-accept permission requests, and agents must never ask for tool permission. `toolPolicy: 'read-only'` always overrides the mode, so supervisor and completion bridges stay read-only even in Full Access, and real `AskUserQuestion` prompts still surface. See `docs/execution-modes.md`. -Reasoning effort choices are provider-native: Claude exposes `off/low/medium/high/xhigh/max`; Codex exposes `off/low/medium/high/xhigh/max/ultra`; other providers expose `off/low/medium/high/xhigh`. Claude `off` must explicitly disable SDK thinking, and its named levels map to SDK `effort`. Codex `xhigh/max/ultra` must pass through app-server unchanged rather than silently downgrade. Codex Ultra's automatic delegation is upstream provider behavior—CrewCode does not emulate it. See `docs/reasoning-effort.md`. +Reasoning effort choices are provider-native: Claude exposes `off/adaptive/low/medium/high/xhigh/max`; Codex exposes `off/low/medium/high/xhigh/max/ultra`; other providers expose `off/low/medium/high/xhigh`. Claude `off` must explicitly disable SDK thinking; `adaptive` omits both SDK `effort` and `thinking` to retain Claude Code's settings/model resolution, and its named levels map to SDK `effort`. Codex `xhigh/max/ultra` must pass through app-server unchanged rather than silently downgrade. Codex Ultra's automatic delegation is upstream provider behavior—CrewCode does not emulate it. See `docs/reasoning-effort.md`. ## Claude SDK Global skills isolation diff --git a/docs/desktop-web-continuity.md b/docs/desktop-web-continuity.md index 5aceea2..6ef11e2 100644 --- a/docs/desktop-web-continuity.md +++ b/docs/desktop-web-continuity.md @@ -81,7 +81,12 @@ session and provider. Every explicit enable also reconciles the current desktop workspace registry into the Brain before attachment: current desktop entries and ordering win for matching ids or -paths, while genuine browser-created workspaces are retained. Immediately after the +paths. A Brain-only workspace is retained only when its observed creation time is newer +than the current desktop registry; older missing entries are treated as intentionally +removed and are not resurrected. The same explicit local enable auditably adds each +existing registered local project as an exact Brain authorization root while preserving +the current scopes; it never grants a broader parent or adds a scope. SSH workspaces +remain desktop-only. Immediately after the Brain attaches, but before Electron reloads, the foreground renderer sends its exact current chat/session and workspace-tab catalogue through the owner-loopback desktop control. This enable-time handoff supersedes an older persisted authority marker, so a @@ -97,12 +102,23 @@ After attachment, the Brain store is authoritative for: - the workspace-tab and chat-session catalogue needed to find the same threads on another client. +Attached Electron continues to read the exact native desktop workspace catalogue so +Brain attachment cannot hide local or SSH projects. Brain/browser `workspaces.list` +returns that same registered catalogue after `workspace:read` is granted. Rows outside +the currently authorized roots remain visible as **Authorize** and SSH rows as +**Desktop**; they cannot be opened and no filesystem, Git, terminal, or agent operation +may cross the existing root gate. +Project icon data remains device-local and is omitted from relayed workspace catalogues +so several large icons cannot disconnect the Brain during browser startup. Relay frames +are bounded to the Hub's existing 8 MiB connection-burst budget; Brain rejects an +oversized result on only the affected logical tunnel before it reaches the Hub. + Each renderer still uses localStorage as a bounded paint/navigation cache. At startup, it hydrates the catalogue plus bounded recent tails for active and locally cached conversation scopes from the Brain. A cold conversation hydrates its own bounded tail when opened. Each remote scope tail is capped at 96 KiB; the 32-scope startup window therefore stays below 3 MiB of transcript plaintext and leaves encryption and other -startup RPCs headroom inside the Hub's shared 8 MiB relay burst. Full shards remain +startup RPCs headroom inside the Hub's shared 8 MiB relay burst and frame ceiling. Full shards remain authoritative on the Brain. The renderer never requests `transcripts.loadAll` over the Brain/Hub relay: combining a large transcript store into one encrypted frame can exceed both the relay frame limit and the JavaScript string limit. Full per-scope shards remain diff --git a/docs/mobile-responsive-pages.md b/docs/mobile-responsive-pages.md index 19b9624..e380e2a 100644 --- a/docs/mobile-responsive-pages.md +++ b/docs/mobile-responsive-pages.md @@ -5,6 +5,26 @@ treat `≤768px` as mobile. The renderer is shared by direct `crewcode serve` an Hub-relayed browser sessions; responsive work must not fork the underlying data or privileged client paths. +## Window tabs + +On phones, the top window-tab strip is replaced by a **Tabs** button and open-tab +count in the existing bottom workspace dock. It opens a scrollable bottom sheet +with the active tab highlighted, observed agent/crew activity, and separate close +buttons. Pinned tabs stay protected from closing. Selecting a tab dismisses the +sheet; **New tab** exposes the same built-in and plugin destinations as desktop. +Escape, the backdrop, the close button, and dragging the handle dismiss the sheet. +Keyboard focus stays within the sheet and returns to its trigger on dismissal. +Desktop retains its existing tab strip. Mobile workspace, Git, and file-tree +overlays start at the top of the viewport without reserving the former 40px strip. + +## Typography + +Hub/mobile renderers apply the same live chat and Code Editor typography settings +as desktop. Agent replies and user bubbles consume `--mono-size`; the phone composer +uses the selected size with a 16px minimum to prevent iOS focus zoom. CodeMirror's +runtime theme must not override `--editor-size`, including after the editor is already +mounted. These preferences remain browser-device-local and update without a reload. + ## Code and Git review - **Code Editor** keeps the code canvas full width. Tabs and status metadata @@ -21,7 +41,7 @@ or privileged client paths. stack above the diff, menus and branch selection use mobile overlay layers, and interactive controls/inputs remain touch-sized and iOS-safe. - **Changes by turn** becomes a full-screen review surface between the mobile - tab bar and workspace dock. With its catalogue visible, turn/file selection + viewport top and workspace dock. With its catalogue visible, turn/file selection stacks above the diff. Direct changed-file targets keep the catalogue closed so the selected Pierre diff receives the full viewport. diff --git a/docs/realtime-voice.md b/docs/realtime-voice.md index 4d60e05..f66d11a 100644 --- a/docs/realtime-voice.md +++ b/docs/realtime-voice.md @@ -211,7 +211,7 @@ renderer transport; selection speech remains capped at 4,000 total characters. ## Audio capture implementation -Microphone capture uses `AudioWorkletNode` for Local voice, one-shot dictation, and xAI Realtime. The shared worklet batches PCM frames off the renderer main thread and transfers them through a message port; a zero-gain sink keeps the graph active without microphone feedback. Do not reintroduce deprecated `ScriptProcessorNode` capture. +Microphone capture uses `AudioWorkletNode` for Local voice, one-shot dictation, and xAI Realtime. The shared worklet batches PCM frames off the renderer main thread and transfers them through a message port; a zero-gain sink keeps the graph active without microphone feedback. Local voice submits an utterance after five seconds without detected speech; this longer pause window avoids cutting off deliberate pauses while speaking. Do not reintroduce deprecated `ScriptProcessorNode` capture. ## Installing local voice diff --git a/docs/reasoning-effort.md b/docs/reasoning-effort.md index 60d3470..84f11ae 100644 --- a/docs/reasoning-effort.md +++ b/docs/reasoning-effort.md @@ -6,11 +6,13 @@ CrewCode exposes provider-native reasoning effort choices in the composer and pe | Provider | Levels | | --- | --- | -| Claude | `off`, `low`, `medium`, `high`, `xhigh`, `max` | +| Claude | `off`, `adaptive`, `low`, `medium`, `high`, `xhigh`, `max` | | Codex | `off`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra` | | Other bridge providers | `off`, `low`, `medium`, `high`, `xhigh` | -The picker is provider-aware. When switching providers, CrewCode resets an unsupported selected level to `medium` instead of silently downgrading it. +The picker is provider-aware. When switching providers, the composer resets an +unsupported selected level to the first offered level (or `off` when no effort +controls exist). Adaptive is offered only for Claude. ## Provider mapping @@ -18,6 +20,17 @@ The picker is provider-aware. When switching providers, CrewCode resets an unsup Claude levels map directly to the Claude Agent SDK `effort` option. `off` is different: Claude enables adaptive thinking by default, so CrewCode sends `thinking: { type: 'disabled' }` explicitly. +**Adaptive** is a CrewCode selection, not an SDK effort value. It omits both +`effort` and `thinking` from `query()` options, letting Claude Code resolve its +effort from the environment, user/project settings, and model defaults. It does +not clear those settings or force a particular numeric level. The choice is +Claude-only and appears after Off in desktop, mobile, and crew effort menus. +Named effort levels guide adaptive reasoning too; supported levels depend on +the selected model and installed Claude Code version. CrewCode passes named +levels through without inventing model support or silently replacing them. +See [Claude Code effort configuration](https://code.claude.com/docs/en/model-config#adjust-effort-level) +and the [Agent SDK effort option](https://code.claude.com/docs/en/agent-sdk/agent-loop#effort-level). + Claude `max` is maximum native reasoning effort. It does not enable automatic task delegation. The SDK's separate ultracode workflow setting is intentionally not represented as a reasoning effort. **Redacted thinking**: Opus-class models with effort thinking stream `thinking_delta` events whose `thinking` text is empty — only an `estimated_tokens` counter is exposed. This is SDK behavior, not a CrewCode bug: there is no reasoning text to render as a THOUGHTS block. The claude bridge surfaces these as a transient `Thinking… ~N tokens` status (cleared when the first text/tool block opens) rather than a thinking row. Models/configs that do stream thinking text still produce normal THOUGHTS blocks. diff --git a/docs/security-model.md b/docs/security-model.md index 32b6b86..679fb48 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -49,6 +49,10 @@ must match the request's exact origin or a CLI-configured `--public-origin`. Pairing and failed-session attempts use bounded per-peer fixed-window limits. The brain still revalidates registered workspace roots for filesystem, Git, PTY, and agent operations; transport authentication does not widen filesystem scope. +With `workspace:read`, the bounded registered-workspace catalogue may include metadata +for rows outside current Brain roots so desktop and browser navigation keep one +authoritative project list. Those rows are explicitly marked unavailable; content and +execution operations still require both registration and an authorized live root. **Tests:** `remote-access-auth.test.ts`, `remote-access-security.test.ts`, and `remote-access-server.test.ts` cover persistence/restart, expiry, revocation, @@ -97,6 +101,9 @@ and never accepts arbitrary destinations. Browser and Brain then use ephemeral P handshake transcript with its enrolled Ed25519 key, and ordered application frames use direction-separated HKDF/AES-256-GCM keys. The Hub sees routing metadata and handshake public values, but not RPC, source, terminal, prompt, or response plaintext. +Individual relay frames are capped at the same 8 MiB burst ceiling. Brain preflights +encoded outbound frames so an oversized RPC result closes only its logical browser +tunnel instead of causing the Hub to evict the machine relay. Hub identity still does not grant execution. `crewcode hub --local-brain` enrolls the Hub host only after the owner passkey exists, then spawns a sibling Brain; @@ -109,6 +116,9 @@ stop affected agents/terminals, and remove scopes from existing sessions; additi require a fresh ticket and handshake. Each decrypted method must be included in both the ticket request and current local grant. The backend revalidates live workspace roots for filesystem, Git, PTY, attachments, and agent calls. +Enabling Electron Background Brain is itself an explicit owner-local authorization +event: it records exact existing desktop project paths as roots without adding scopes +or granting a parent directory. Standalone/headless Brain startup remains unchanged. **Tests:** `hub-server.test.ts` covers CSRF, issue/enroll, local-brain owner gating, replay rejection, stale diff --git a/src/main/agents/bridge-types.ts b/src/main/agents/bridge-types.ts index ae8f173..c3a920d 100644 --- a/src/main/agents/bridge-types.ts +++ b/src/main/agents/bridge-types.ts @@ -131,7 +131,7 @@ export type BridgeEvent = // at the next turn instead. Not a violation — the deferral IS the enforcement. | { type: 'custody_deferred'; bridgeId: string; message: string } -export type EffortLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' +export type EffortLevel = 'off' | 'adaptive' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' export type { ModeLevel } from '../../shared/mode-types' export type BridgeToolPolicy = 'default' | 'read-only' diff --git a/src/main/agents/claude-bridge.test.ts b/src/main/agents/claude-bridge.test.ts index e4b23a8..4f3514b 100644 --- a/src/main/agents/claude-bridge.test.ts +++ b/src/main/agents/claude-bridge.test.ts @@ -132,6 +132,19 @@ describe('claude bridge mode options', () => { expect(queryMock.mock.calls[1][0].options.thinking).toBeUndefined() }) + it('leaves effort and thinking unset for Adaptive, including after resuming a session', async () => { + const bridge = await createClaudeBridge('/bin/claude', { + bridgeId: 'adaptive', provider: 'claude', cwd: '/repo', thinking: 'adaptive', + resumeSessionId: 'existing-session', + }, vi.fn()) + await bridge.prompt('hello') + + const options = queryMock.mock.calls[0][0].options + expect(options.resume).toBe('existing-session') + expect(options).not.toHaveProperty('effort') + expect(options).not.toHaveProperty('thinking') + }) + it('echoes tool input when allowing Claude permission requests', () => { const input = { command: 'git status' } diff --git a/src/main/agents/claude-bridge.ts b/src/main/agents/claude-bridge.ts index 7b03c79..baaac69 100644 --- a/src/main/agents/claude-bridge.ts +++ b/src/main/agents/claude-bridge.ts @@ -948,11 +948,11 @@ export async function createClaudeBridge( // Overlay only the resolved auto-compaction scalar; do not replace // the rest of the user's Claude settings payload. ...(compactionSettings ? { settings: compactionSettings } : {}), - // Claude defaults to adaptive thinking, so "off" must disable it - // explicitly; named levels map directly to the SDK effort contract. + // Adaptive leaves Claude's settings/model resolution intact; it + // is a UI choice, not a native SDK effort value. Off disables thinking. ...(claudeThinking === 'off' ? { thinking: { type: 'disabled' as const } } - : claudeThinking + : claudeThinking && claudeThinking !== 'adaptive' ? { effort: claudeThinking } : {}), permissionMode: mode.permissionMode, diff --git a/src/main/agents/crewcoder-bridge.test.ts b/src/main/agents/crewcoder-bridge.test.ts index d2fa786..127a92e 100644 --- a/src/main/agents/crewcoder-bridge.test.ts +++ b/src/main/agents/crewcoder-bridge.test.ts @@ -255,18 +255,20 @@ describe('CrewCoder agent modes', () => { })) }) - it.each(['always', 'never', 'full-access', 'sandboxed'] as const)('passes explicit %s approval to the ACP process', async (approval) => { - const harness = crewCoderAcpHarness() - spawnAgentProcess.mockResolvedValue({ proc: harness.proc, dir: '/repo', remote: false }) - - await createCrewCoderBridge('crewcoder', { - bridgeId: 'bridge', provider: 'crewcoder', cwd: '/repo', mode: 'build', - crewcoderMode: 'crewcoder', crewcoderApprovalMode: approval, - }, () => {}) - - expect(spawnAgentProcess).toHaveBeenCalledWith(expect.objectContaining({ - args: ['acp', '--approval', approval, '--mode', 'crewcoder'], - })) + describe.each(['general', 'crewcoder', 'plugin', 'extension', undefined] as const)('approval in %s profile', (profile) => { + it.each(['review', 'always', 'never', 'full-access', 'sandboxed'] as const)('passes explicit %s approval to the ACP process', async (approval) => { + const harness = crewCoderAcpHarness() + spawnAgentProcess.mockResolvedValue({ proc: harness.proc, dir: '/repo', remote: false }) + + await createCrewCoderBridge('crewcoder', { + bridgeId: 'bridge', provider: 'crewcoder', cwd: '/repo', mode: 'build', + crewcoderMode: profile, crewcoderApprovalMode: approval, + }, () => {}) + + expect(spawnAgentProcess).toHaveBeenCalledWith(expect.objectContaining({ + args: ['acp', '--approval', approval, ...(profile ? ['--mode', profile] : [])], + })) + }) }) it('defaults invalid persisted input to general at the process boundary', async () => { diff --git a/src/main/agents/crewcoder-bridge.ts b/src/main/agents/crewcoder-bridge.ts index f2dc0b1..2e899ab 100644 --- a/src/main/agents/crewcoder-bridge.ts +++ b/src/main/agents/crewcoder-bridge.ts @@ -14,7 +14,7 @@ import type { import { contextWindowFor } from './model-context' import { enrichUsageContextWindow } from './openrouter-model-context' import { tripwireForToolCall } from './dangerous-command' -import { crewCoderApprovalForProfile, normalizeCrewCoderMode } from '../../shared/crewcoder-types' +import { normalizeCrewCoderApprovalMode, normalizeCrewCoderMode } from '../../shared/crewcoder-types' // CrewCoder is an ACP agent. CrewCode is the client: it spawns `crewcoder acp` // and maps the newline-delimited JSON-RPC stream onto the shared BridgeEvent API. @@ -445,7 +445,7 @@ export async function createCrewCoderBridge( emit: EmitFn, requestUser?: RequestUserFn, ): Promise { - const args = ['acp', '--approval', crewCoderApprovalForProfile(opts.crewcoderMode, opts.crewcoderApprovalMode)] + const args = ['acp', '--approval', normalizeCrewCoderApprovalMode(opts.crewcoderApprovalMode)] if (opts.crewcoderMode) args.push('--mode', normalizeCrewCoderMode(opts.crewcoderMode)) const selectedModel = splitModel(opts.model) const env: Record = { ...(opts.env ?? {}) } diff --git a/src/main/agents/custody.test.ts b/src/main/agents/custody.test.ts index 58f7ff9..e1f558c 100644 --- a/src/main/agents/custody.test.ts +++ b/src/main/agents/custody.test.ts @@ -37,6 +37,13 @@ describe('scopeViolation', () => { }) describe('authorityOf', () => { + it.each(['general', 'crewcoder', 'plugin', 'extension', undefined] as const)('records native approval for %s profile', (profile) => { + expect(authorityOf({ + bridgeId: 'br-1', provider: 'crewcoder', cwd: '/repo', mode: 'build', + crewcoderMode: profile, crewcoderApprovalMode: 'full-access', + }).crewcoderApprovalMode).toBe('full-access') + }) + it('snapshots exactly the fields a grant is scoped to', () => { expect(authorityOf({ bridgeId: 'br-1', provider: 'claude', cwd: '/repo', mode: 'full', diff --git a/src/main/agents/custody.ts b/src/main/agents/custody.ts index 740f00a..6ca5070 100644 --- a/src/main/agents/custody.ts +++ b/src/main/agents/custody.ts @@ -12,7 +12,7 @@ import { isRemoteRoot } from '../remote/ssh-target' import { CustodyJournal, custodyScopeKey } from './custody-journal' import { normalizeAuthority, violation, type CustodyAuthority, type CustodyViolation } from './custody-invariants' import type { BridgeStartOpts } from './bridge-types' -import { crewCoderApprovalForProfile } from '../../shared/crewcoder-types' +import { normalizeCrewCoderApprovalMode } from '../../shared/crewcoder-types' const { app } = electron @@ -40,7 +40,7 @@ export function authorityOf(opts: BridgeStartOpts): CustodyAuthority { cwd: opts.cwd, mode: opts.mode, crewcoderMode: opts.crewcoderMode, - crewcoderApprovalMode: crewCoderApprovalForProfile(opts.crewcoderMode, opts.crewcoderApprovalMode), + crewcoderApprovalMode: opts.provider === 'crewcoder' ? normalizeCrewCoderApprovalMode(opts.crewcoderApprovalMode) : 'review', toolPolicy: opts.toolPolicy, externalDirectories: opts.externalDirectories, mcpServers: opts.mcpServers, diff --git a/src/main/brain-desktop-service.test.ts b/src/main/brain-desktop-service.test.ts index c251d7a..46abf02 100644 --- a/src/main/brain-desktop-service.test.ts +++ b/src/main/brain-desktop-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { describe, expect, it, vi } from 'vitest' @@ -9,8 +9,9 @@ import { removeBrainDesktopConnection, writeBrainDesktopConnection, } from './brain-desktop-rendezvous' -import { BrainDesktopService, mergeDesktopWorkspacesIntoBrainRuntime, seedBrainRuntime } from './brain-desktop-service' +import { authorizeDesktopWorkspacesForBrain, BrainDesktopService, mergeDesktopWorkspacesIntoBrainRuntime, seedBrainRuntime } from './brain-desktop-service' import { createMachineIdentity, machineCredentialPath, writeMachineCredential } from './hub-machine-enrollment' +import { BrainAuthorizationPolicy, brainAuthorizationPolicyPath } from './brain-authorization-policy' function fixture(): { root: string; desktop: string; brain: string } { const root = mkdtempSync(join(tmpdir(), 'crewcode-desktop-brain-')) @@ -105,29 +106,70 @@ describe('desktop Brain lifecycle', () => { } finally { rmSync(root, { recursive: true, force: true }) } }) - it('puts current desktop workspaces first while retaining browser-created workspaces on enable', () => { + it('uses the current desktop registry without resurrecting older Brain-only workspaces', () => { const { root, desktop, brain } = fixture() try { mkdirSync(join(brain, 'runtime'), { recursive: true }) writeFileSync(join(desktop, 'workspaces.json'), JSON.stringify({ workspaces: [ - { id: 'shared', path: '/shared', name: 'Current desktop name' }, - { id: 'desktop-new', path: '/desktop-new', name: 'Recent desktop workspace' }, + { id: 'shared', path: '/shared', name: 'Current desktop name', addedAt: 100 }, + { id: 'desktop-new', path: '/desktop-new', name: 'Recent desktop workspace', addedAt: 200 }, ] })) + utimesSync(join(desktop, 'workspaces.json'), new Date(1_000), new Date(1_000)) writeFileSync(join(brain, 'runtime', 'workspaces.json'), JSON.stringify({ workspaces: [ - { id: 'shared', path: '/shared', name: 'Old Brain name' }, - { id: 'web-new', path: '/web-new', name: 'Browser workspace' }, + { id: 'shared', path: '/shared', name: 'Old Brain name', addedAt: 100 }, + { id: 'retired', path: '/retired', name: 'Retired workspace', addedAt: 500 }, + { id: 'web-new', path: '/web-new', name: 'Browser workspace', addedAt: 1_500 }, ] })) mergeDesktopWorkspacesIntoBrainRuntime(desktop, brain) expect(JSON.parse(readFileSync(join(brain, 'runtime', 'workspaces.json'), 'utf8')).workspaces).toEqual([ - { id: 'shared', path: '/shared', name: 'Current desktop name' }, - { id: 'desktop-new', path: '/desktop-new', name: 'Recent desktop workspace' }, - { id: 'web-new', path: '/web-new', name: 'Browser workspace' }, + { id: 'shared', path: '/shared', name: 'Current desktop name', addedAt: 100 }, + { id: 'desktop-new', path: '/desktop-new', name: 'Recent desktop workspace', addedAt: 200 }, + { id: 'web-new', path: '/web-new', name: 'Browser workspace', addedAt: 1_500 }, ]) } finally { rmSync(root, { recursive: true, force: true }) } }) + it('honors an intentionally empty current desktop registry', () => { + const { root, desktop, brain } = fixture() + try { + mkdirSync(join(brain, 'runtime'), { recursive: true }) + writeFileSync(join(desktop, 'workspaces.json'), '{"workspaces":[]}') + utimesSync(join(desktop, 'workspaces.json'), new Date(2_000), new Date(2_000)) + writeFileSync(join(brain, 'runtime', 'workspaces.json'), JSON.stringify({ workspaces: [ + { id: 'retired', path: '/retired', name: 'Retired workspace', addedAt: 500 }, + ] })) + + mergeDesktopWorkspacesIntoBrainRuntime(desktop, brain) + + expect(JSON.parse(readFileSync(join(brain, 'runtime', 'workspaces.json'), 'utf8')).workspaces).toEqual([]) + } finally { rmSync(root, { recursive: true, force: true }) } + }) + + it('auditably authorizes exact current local workspaces without granting scopes or SSH roots', () => { + const { root, desktop, brain } = fixture() + try { + const manualRoot = join(root, 'manual') + const currentRoot = join(root, 'current') + mkdirSync(manualRoot) + mkdirSync(currentRoot) + writeFileSync(join(desktop, 'workspaces.json'), JSON.stringify({ workspaces: [ + { id: 'current', path: currentRoot, kind: 'folder', addedAt: 1 }, + { id: 'ssh', path: 'ssh://devbox/srv/app', kind: 'remote', addedAt: 2 }, + ] })) + new BrainAuthorizationPolicy(brainAuthorizationPolicyPath(brain), [manualRoot], ['workspace:read']) + + authorizeDesktopWorkspacesForBrain(desktop, brain) + + const policy = new BrainAuthorizationPolicy(brainAuthorizationPolicyPath(brain), [], []).current() + expect(policy.roots).toEqual([currentRoot, manualRoot].sort()) + expect(policy.scopes).toEqual(['workspace:read']) + expect(policy.audit.at(-1)).toMatchObject({ userId: 'desktop-background-enable' }) + expect(JSON.stringify(policy)).not.toContain('ssh://') + } finally { rmSync(root, { recursive: true, force: true }) } + }) + it('starts, probes, and explicitly stops the detached Brain', async () => { const { root, desktop, brain } = fixture() const descriptor = connection() diff --git a/src/main/brain-desktop-service.ts b/src/main/brain-desktop-service.ts index 89af5bc..8238885 100644 --- a/src/main/brain-desktop-service.ts +++ b/src/main/brain-desktop-service.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from 'child_process' import { createHash } from 'crypto' -import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'fs' -import { join } from 'path' +import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from 'fs' +import { join, sep } from 'path' import WebSocket from 'ws' import type { BrainDesktopConnection, BrainDesktopStatus } from '../shared/brain-desktop-types' import { CREWCODE_REMOTE_PROTOCOL_VERSION, type CrewCodeRemoteResponse } from '../shared/remote-access-types' @@ -14,6 +14,7 @@ import { } from './brain-desktop-rendezvous' import { defaultBrainDataDir, loadMachineCredentialIfPresent, machineCredentialPath, normalizeHubUrl } from './hub-machine-enrollment' import { mergeTranscriptMessages } from './transcript-service' +import { BrainAuthorizationPolicy, brainAuthorizationPolicyPath } from './brain-authorization-policy' const START_TIMEOUT_MS = 12_000 const POLL_MS = 150 @@ -128,38 +129,51 @@ export function seedBrainRuntime(desktopDataDir: string, brainDataDir: string): interface StoredWorkspaceRow { id: string path: string + kind?: unknown + addedAt?: unknown [key: string]: unknown } -function readWorkspaceRows(path: string): StoredWorkspaceRow[] { - if (!existsSync(path)) return [] +function readWorkspaceRows(path: string): StoredWorkspaceRow[] | null { + if (!existsSync(path)) return null try { const parsed = JSON.parse(readFileSync(path, 'utf8')) as { workspaces?: unknown } - if (!Array.isArray(parsed.workspaces)) return [] - return parsed.workspaces.filter((row): row is StoredWorkspaceRow => { + if (!Array.isArray(parsed.workspaces)) return null + const valid = parsed.workspaces.every((row): row is StoredWorkspaceRow => { if (!row || typeof row !== 'object') return false const candidate = row as { id?: unknown; path?: unknown } return typeof candidate.id === 'string' && candidate.id.length > 0 && typeof candidate.path === 'string' && candidate.path.length > 0 }) + return valid ? parsed.workspaces : null } catch { - return [] + return null } } /** On explicit enable, prefer the current desktop registry while retaining * workspaces that were genuinely added through a prior browser session. */ export function mergeDesktopWorkspacesIntoBrainRuntime(desktopDataDir: string, brainDataDir: string): void { - const desktopRows = readWorkspaceRows(join(desktopDataDir, 'workspaces.json')) - if (desktopRows.length === 0) return + const desktopPath = join(desktopDataDir, 'workspaces.json') + const desktopRows = readWorkspaceRows(desktopPath) + if (!desktopRows) return + const desktopUpdatedAt = statSync(desktopPath).mtimeMs const runtimeDir = join(brainDataDir, 'runtime') const brainPath = join(runtimeDir, 'workspaces.json') - const brainRows = readWorkspaceRows(brainPath) + const brainRows = readWorkspaceRows(brainPath) ?? [] const desktopIds = new Set(desktopRows.map(row => row.id)) const desktopPaths = new Set(desktopRows.map(row => row.path)) const merged = [ ...desktopRows, - ...brainRows.filter(row => !desktopIds.has(row.id) && !desktopPaths.has(row.path)), + // A newer Brain-only row can be legitimate browser work; older missing rows + // were removed from the current desktop registry and must not be resurrected. + ...brainRows.filter(row => ( + !desktopIds.has(row.id) + && !desktopPaths.has(row.path) + && typeof row.addedAt === 'number' + && Number.isFinite(row.addedAt) + && row.addedAt > desktopUpdatedAt + )), ] mkdirSync(runtimeDir, { recursive: true, mode: 0o700 }) const temporary = `${brainPath}.${process.pid}.${Date.now().toString(36)}.tmp` @@ -167,6 +181,24 @@ export function mergeDesktopWorkspacesIntoBrainRuntime(desktopDataDir: string, b renameSync(temporary, brainPath) } +/** Enabling Background Brain is an explicit local authorization event. Grant + * exact registered paths, never a broader parent, and preserve existing scopes. */ +export function authorizeDesktopWorkspacesForBrain(desktopDataDir: string, brainDataDir: string): void { + const desktopRows = readWorkspaceRows(join(desktopDataDir, 'workspaces.json')) + if (!desktopRows) return + const policy = new BrainAuthorizationPolicy(brainAuthorizationPolicyPath(brainDataDir), [], []) + const current = policy.current() + const roots = [...current.roots] + for (const workspace of desktopRows) { + if (workspace.kind === 'remote' || !existsSync(workspace.path) || !statSync(workspace.path).isDirectory()) continue + const root = realpathSync(workspace.path) + if (roots.some(existing => root === existing || root.startsWith(existing + sep))) continue + roots.push(root) + } + if (roots.length === current.roots.length) return + policy.update({ roots, scopes: current.scopes, userId: 'desktop-background-enable' }) +} + export class BrainDesktopService { private readonly dataDir: string private readonly connectionPath: string @@ -244,6 +276,7 @@ export class BrainDesktopService { writeBrainDesktopEnabled(this.preferencesPath, enabled) if (enabled) { mergeDesktopWorkspacesIntoBrainRuntime(this.options.desktopDataDir, this.dataDir) + authorizeDesktopWorkspacesForBrain(this.options.desktopDataDir, this.dataDir) await this.start() } return this.status() diff --git a/src/main/hub-brain-relay.ts b/src/main/hub-brain-relay.ts index 1eaf14a..59fc869 100644 --- a/src/main/hub-brain-relay.ts +++ b/src/main/hub-brain-relay.ts @@ -7,6 +7,7 @@ import { type CrewCodeRemoteResponse, } from '../shared/remote-access-types' import { + HUB_RELAY_MAX_FRAME_BYTES, type BrainAccessScope, type HubRelayControlFrame, type HubTunnelPlaintext, @@ -225,6 +226,10 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise HUB_RELAY_MAX_FRAME_BYTES) { + closeForFrameFailure('send oversized', new Error(`encoded frame exceeds ${HUB_RELAY_MAX_FRAME_BYTES} bytes`)) + return + } session.outboundSending = true activeRelay.send(encoded, error => { session.outboundSending = false diff --git a/src/main/remote-access-server.test.ts b/src/main/remote-access-server.test.ts index 21bb1d0..6064951 100644 --- a/src/main/remote-access-server.test.ts +++ b/src/main/remote-access-server.test.ts @@ -1,10 +1,11 @@ -import { mkdtempSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'fs' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'fs' import { createHash } from 'crypto' import { join } from 'path' import { tmpdir as osTmpdir } from 'os' import { afterEach, describe, expect, it, vi } from 'vitest' import { CREWCODE_REMOTE_PROTOCOL_VERSION } from '../shared/remote-access-types' import { DESKTOP_CATALOGUE_AUTHORITY_KEY } from '../shared/continuity-state-types' +import { HUB_RELAY_MAX_FRAME_BYTES } from '../shared/hub-relay-types' import { startRemoteAccessServer, type RunningRemoteAccessServer } from './remote-access-server' import WebSocket from 'ws' @@ -106,6 +107,47 @@ describe('remote access server', () => { expect(await rpc.json()).toMatchObject({ id: 'one', ok: true, result: [] }) }) + it('returns the authoritative registered catalogue without granting unapproved paths', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'crewcode-catalogue-')) + const allowed = mkdtempSync(join(tmpdir(), 'crewcode-allowed-')) + const blocked = mkdtempSync(join(tmpdir(), 'crewcode-blocked-')) + const allowedTwo = join(allowed, 'two') + const allowedThree = join(allowed, 'three') + mkdirSync(allowedTwo) + mkdirSync(allowedThree) + writeFileSync(join(allowed, 'icon.png'), Buffer.alloc(512 * 1024)) + writeFileSync(join(allowedTwo, 'icon.png'), Buffer.alloc(512 * 1024)) + writeFileSync(join(allowedThree, 'icon.png'), Buffer.alloc(512 * 1024)) + writeFileSync(join(dataDir, 'workspaces.json'), JSON.stringify({ workspaces: [ + { id: 'allowed', name: 'Allowed', path: allowed, kind: 'folder', pinned: false, addedAt: 1 }, + { id: 'allowed-two', name: 'Allowed two', path: allowedTwo, kind: 'folder', pinned: false, addedAt: 2 }, + { id: 'allowed-three', name: 'Allowed three', path: allowedThree, kind: 'folder', pinned: false, addedAt: 3 }, + { id: 'blocked', name: 'Blocked', path: blocked, kind: 'folder', pinned: false, addedAt: 2 }, + { id: 'ssh', name: 'SSH', path: 'ssh://devbox/srv/app', kind: 'remote', pinned: false, addedAt: 3, remote: { host: 'devbox' } }, + ] })) + running = await startRemoteAccessServer({ dataDir, allowedWorkspaceRoots: [allowed] }) + const pair = await fetch(`${running.url}/api/v1/pair`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: running.pairingToken }) }) + const { sessionToken } = await pair.json() as { sessionToken: string } + const rpc = (id: string, method: string, params: Record) => fetch(`${running!.url}/api/v1/rpc`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ protocolVersion: 1, id, method, params }), + }) + + const catalogueText = await (await rpc('list', 'workspaces.list', {})).text() + expect(Buffer.byteLength(catalogueText)).toBeLessThan(HUB_RELAY_MAX_FRAME_BYTES) + expect(JSON.parse(catalogueText)).toMatchObject({ + ok: true, + result: [ + { id: 'allowed', brainAccess: 'authorized', projectIconDataUrl: null }, + { id: 'allowed-two', brainAccess: 'authorized', projectIconDataUrl: null }, + { id: 'allowed-three', brainAccess: 'authorized', projectIconDataUrl: null }, + { id: 'blocked', brainAccess: 'requires-authorization' }, + { id: 'ssh', brainAccess: 'desktop-only' }, + ], + }) + expect((await rpc('blocked-read', 'fs.readDir', { root: blocked, rel: '' })).status).toBe(403) + }) + it('restricts project onboarding to configured roots and creates projects there', async () => { const server = await start() const parent = mkdtempSync(join(tmpdir(), 'crewcode-project-parent-')) diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index d9acbda..9c56ac6 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -20,7 +20,7 @@ import { remotePeerKey, RemoteAccessRateLimiter, } from './remote-access-security' -import { WorkspaceService } from './workspace-service' +import { WorkspaceService, type StoredWorkspace } from './workspace-service' import { PtyService } from './pty-service' import { AgentBridgeService, webConversationKey, type AgentPathResolver } from './agents/bridge-service' import { headlessAgentRegistry, listHeadlessAgentModels } from './headless-agent-resolver' @@ -198,6 +198,31 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions } return resolved } + const catalogueFallback = (workspace: StoredWorkspace, brainAccess: 'requires-authorization' | 'desktop-only') => ({ + ...workspace, + folder: workspace.folder ?? null, + remote: workspace.remote ?? null, + branch: null, + dirty: 0, + status: workspace.kind === 'remote' ? 'idle' : 'error', + agents: [], + updated: '', + projectIconDataUrl: null, + brainAccess, + }) + const listWorkspaceCatalogue = () => workspaceService.listStored().map(workspace => { + if (workspace.kind === 'remote') return catalogueFallback(workspace, 'desktop-only') + try { + allowedPath(workspace.path) + // Local data URLs can be hundreds of KiB each; relaying several in one + // catalogue response would exceed the encrypted Hub frame budget. + return { ...workspaceService.inspect(workspace.id)!, projectIconDataUrl: null, brainAccess: 'authorized' as const } + } catch { + // Registered metadata stays visible so Brain cannot replace the desktop + // catalogue, but path contents remain unavailable until explicitly granted. + return catalogueFallback(workspace, 'requires-authorization') + } + }) const attachmentDirectory = (root: string): string => { const crewDirectory = join(root, '.crewcode') if (!existsSync(crewDirectory)) mkdirSync(crewDirectory, { mode: 0o700 }) @@ -223,14 +248,14 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions // carrying that behavior onto the network would expose the whole host filesystem. const registeredRoot = (params: Record): string => { const root = allowedPath(params.root) - if (!workspaceService.list().some(workspace => workspace.path === root)) { + if (!workspaceService.listStored().some(workspace => workspace.path === root)) { throw Object.assign(new Error('filesystem root is not a registered workspace'), { remoteCode: 'FORBIDDEN' }) } return root } const registeredWorkspaceId = (value: unknown): string => { const id = String(value ?? '') - const workspace = workspaceService.list().find(item => item.id === id) + const workspace = workspaceService.listStored().find(item => item.id === id) if (!workspace) return id try { allowedPath(workspace.path) } catch { throw Object.assign(new Error('workspace is outside the server workspace roots'), { remoteCode: 'FORBIDDEN' }) @@ -270,9 +295,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['app.buildInfo', () => createAppBuildInfo()], ['auth.sessions', () => auth.list()], ['auth.revoke', params => ({ revoked: auth.revoke(String(params.sessionId ?? '')) })], - ['workspaces.list', () => workspaceService.list().filter(workspace => { - try { allowedPath(workspace.path); return true } catch { return false } - })], + ['workspaces.list', listWorkspaceCatalogue], ['workspaces.inspectPath', params => { const path = allowedPath(params.path) if (!statSync(path).isDirectory()) throw new Error('path is not a directory') diff --git a/src/main/workspace-service.test.ts b/src/main/workspace-service.test.ts index c681da8..d128d08 100644 --- a/src/main/workspace-service.test.ts +++ b/src/main/workspace-service.test.ts @@ -20,6 +20,8 @@ describe('WorkspaceService', () => { expect(service.pin(added.workspace!.id, true)).toEqual({ ok: true }) expect(service.setFolder(added.workspace!.id, 'backend')).toEqual({ ok: true }) expect(service.list()[0]).toMatchObject({ name: 'renamed', pinned: true, folder: 'backend', path: project }) + expect(service.listStored()[0]).toMatchObject({ name: 'renamed', pinned: true, folder: 'backend', path: project }) + expect(service.inspect(added.workspace!.id)).toMatchObject({ name: 'renamed', path: project }) expect(JSON.parse(readFileSync(join(root, 'state', 'workspaces.json'), 'utf8')).workspaces).toHaveLength(1) }) diff --git a/src/main/workspace-service.ts b/src/main/workspace-service.ts index f0f7ba1..c01c180 100644 --- a/src/main/workspace-service.ts +++ b/src/main/workspace-service.ts @@ -360,6 +360,20 @@ export class WorkspaceService { return readStore(this.storeFilePath).workspaces.map(enrich) } + /** Catalogue metadata is safe to inspect before any filesystem authority is + * granted; callers must still gate every operation that touches a path. */ + listStored(): StoredWorkspace[] { + return readStore(this.storeFilePath).workspaces.map(workspace => ({ + ...workspace, + remote: workspace.remote ? { ...workspace.remote } : workspace.remote, + })) + } + + inspect(id: string): EnrichedWorkspace | null { + const workspace = readStore(this.storeFilePath).workspaces.find(item => item.id === id) + return workspace ? enrich(workspace) : null + } + add(absPath: string): { ok?: boolean; error?: string; workspace?: EnrichedWorkspace } { if (!absPath) return { error: 'no path' } if (!existsSync(absPath)) return { error: `path does not exist: ${absPath}` } diff --git a/src/preload/index.ts b/src/preload/index.ts index a7ce42d..502c66e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -168,7 +168,7 @@ contextBridge.exposeInMainWorld('electronAPI', { crewcoderMode?: import('../shared/crewcoder-types').CrewCoderMode crewcoderApprovalMode?: import('../shared/crewcoder-types').CrewCoderApprovalMode toolPolicy?: 'default' | 'read-only' - thinking?: 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' + thinking?: 'off' | 'adaptive' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' apiKey?: string env?: Record sessionKey?: string // "tabId:agentId" — used to look up the resume id diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c44c7ca..d2bd671 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -82,7 +82,7 @@ import { knownModelIds } from './hooks/useProviderModels' import { dockUsageProviderId } from './hooks/dock-usage-provider' import { ChatNotifications } from './components/thread/ChatNotifications' import { useGlobalShortcuts } from './hooks/useGlobalShortcuts' -import { useMobileWindowTabsAutoHide } from './hooks/useMobileWindowTabsAutoHide' +import { MobileWindowTabs } from './components/ui/MobileWindowTabs' import { LOCAL_SHORTCUTS, effectiveChord, matchesChord, type ActionId } from './shortcuts' import { useTerminalSessions } from './hooks/useTerminalSessions' import { useTerminalUnreadSync, useClearPane } from './stores/terminal-unread-store' @@ -300,10 +300,14 @@ export default function App() { // ── Active workspace ───────────────────────────────────────────────────── const [activeWs, setActiveWs] = useState(() => settings.onLaunch === 'last session' ? readLastActiveWorkspaceId() : '') useEffect(() => { - if (activeWs && ws.workspaces.some(w => w.id === activeWs)) return - if (ws.workspaces.length === 0) return + const available = ws.workspaces.filter(workspace => !workspace.brainAccess || workspace.brainAccess === 'authorized') + if (activeWs && available.some(w => w.id === activeWs)) return + if (available.length === 0) { + if (activeWs) setActiveWs('') + return + } const saved = settings.onLaunch === 'last session' ? readLastActiveWorkspaceId() : '' - const restored = saved && ws.workspaces.some(w => w.id === saved) ? saved : ws.workspaces[0].id + const restored = saved && available.some(w => w.id === saved) ? saved : available[0].id setActiveWs(restored) }, [ws.workspaces, activeWs, settings.onLaunch]) @@ -338,6 +342,9 @@ export default function App() { }, [setGitWidthByTab]) // ── Mobile shell ────────────────────────────────────────────────────────── const mobile = useMobileShell() + useEffect(() => { + if (!mobile.isMobile) mobile.closeSheet('tabs') + }, [mobile.isMobile, mobile.closeSheet]) const [storedMobileDrawerSide, setMobileDrawerSide] = useLocalStorageJsonState<'left' | 'right'>(MOBILE_DRAWER_SIDE_STORAGE, 'left') const mobileDrawerSide = storedMobileDrawerSide === 'right' ? 'right' : 'left' // A desktop bottom-drawer preference must never turn into a bottom sheet on @@ -2376,11 +2383,7 @@ export default function App() { // tab and menulet read it through context via MissionControlHost/MenuletHost. const [menuletOpen, setMenuletOpen] = useState(false) const [systemMonitorOpen, setSystemMonitorOpen] = useState(false) - const [windowTabsMenuOpen, setWindowTabsMenuOpen] = useState(false) - const windowTabsHidden = useMobileWindowTabsAutoHide({ - enabled: mobile.isMobile, - locked: windowTabsMenuOpen || drawerOpen || menuletOpen || systemMonitorOpen, - }) + const openMissionControl = useCallback((): void => { setMenuletOpen(false) handleNewTab('mission') @@ -3554,6 +3557,20 @@ export default function App() { title: 'More', content: }, + tabs: { + open: mobile.sheets.tabs?.open ?? false, + title: 'Tabs', + content: { setActiveTabId(id); mobile.closeSheet('tabs') }} + onClose={handleCloseTab} + onNewTab={kind => { handleAppMenuAction({ kind: 'open-tab', tab: kind }); mobile.closeSheet('tabs') }} + pluginMenuItems={pluginAddMenuItems} + onPluginMenuItem={item => { runPluginActionTarget(item.target, { source: 'plugin-menu' }); mobile.closeSheet('tabs') }} + />, + }, 'mission-activity': { open: mobile.sheets['mission-activity']?.open ?? false, title: 'Activity', @@ -3576,7 +3593,7 @@ export default function App() { /> )} -
+ {!mobile.isMobile &&
runPluginActionTarget(item.target, { source: 'plugin-menu' })} - onNewTabMenuOpenChange={setWindowTabsMenuOpen} /> -
+
} w.id === activeWs)} - onToggle={() => setDrawerOpen(o => !o)} + onToggle={() => { mobile.closeSheet('tabs'); setDrawerOpen(o => !o) }} + onOpenTabs={mobile.isMobile ? () => { setDrawerOpen(false); mobile.onSheetToggle('tabs') } : undefined} + tabsOpen={mobile.sheets.tabs?.open ?? false} + tabCount={displayTabs.length} pluginStatusItems={pluginStatusItems} onPluginStatusItem={handlePluginStatusItem} activeAgentId={activeAgentId} diff --git a/src/renderer/src/components/chat/ChatPane.tsx b/src/renderer/src/components/chat/ChatPane.tsx index 75f8d43..99428da 100644 --- a/src/renderer/src/components/chat/ChatPane.tsx +++ b/src/renderer/src/components/chat/ChatPane.tsx @@ -38,7 +38,7 @@ import { useVoiceSessionController } from '../../hooks/useVoiceSessionController import { getCrewCodeClient } from '../../runtime/crewcode-client' import { isCrewLaneSessionKey } from '../../../../shared/custody-types' import { isSessionDrag, readSessionDrag, type SessionDragPayload } from '../thread/session-drag' -import { crewCoderApprovalForProfile, crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' +import { normalizeCrewCoderApprovalMode, crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' import { shouldShowChatBackground } from './fresh-chat-background' type CrewBranchWithMessagesProps = Omit, 'messagesByTab'> @@ -306,9 +306,8 @@ export function ChatPane({ const model = activeSession?.model ?? '' const effort = (activeSession?.effort ?? 'medium') as EffortLevel const crewcoderMode = activeSession?.crewcoderMode - // Full access is visible and effective only for the concrete CrewCoder - // profile; switching profiles must never leave hidden elevated authority. - const crewcoderApprovalMode = crewCoderApprovalForProfile(crewcoderMode, activeSession?.crewcoderApprovalMode) + // Approval belongs to the provider session, so profile changes retain it. + const crewcoderApprovalMode = normalizeCrewCoderApprovalMode(activeSession?.crewcoderApprovalMode) const crewCoderProfileActive = crewCoderProfileLocksExecutionMode(activeAgentId, crewcoderMode) const modeLevel = crewCoderProfileActive ? 'build' : normalizeModeLevel(activeSession?.mode ?? settingsDefaultMode) const composerMode: Mode = MODE_FROM_SETTINGS[modeLevel] ?? 'Build' @@ -383,7 +382,6 @@ export function ChatPane({ chatSessions.update(tabId, sessActive, { crewcoderMode: nextMode, ...(nextMode ? { mode: 'build' as const } : {}), - ...(nextMode === 'crewcoder' ? {} : { crewcoderApprovalMode: 'review' as const }), }) }, [tabId, sessActive, chatSessions]) diff --git a/src/renderer/src/components/chat/mobile-chat-layout.test.ts b/src/renderer/src/components/chat/mobile-chat-layout.test.ts index 1b7949d..1833d70 100644 --- a/src/renderer/src/components/chat/mobile-chat-layout.test.ts +++ b/src/renderer/src/components/chat/mobile-chat-layout.test.ts @@ -28,7 +28,7 @@ describe('mobile solo chat layout', () => { it('keeps the mobile composer full-width, compact, and prevents iOS input zoom', () => { expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.composer \{ width: 100%;/) expect(styles).toMatch(/\.composer-wrap \{ padding: 4px 6px/) - expect(styles).toMatch(/\.composer textarea \{[^}]*font-size: 16px;/) + expect(styles).toMatch(/\.composer textarea \{[^}]*font-size: max\(16px, var\(--mono-size, 13\.5px\)\) !important;/) expect(styles).toContain('.mobile-composer-action-button,') expect(styles).toContain('.mobile-composer-model-button {') }) diff --git a/src/renderer/src/components/composer/EffortPicker.test.ts b/src/renderer/src/components/composer/EffortPicker.test.ts index 66edf31..3cf94f6 100644 --- a/src/renderer/src/components/composer/EffortPicker.test.ts +++ b/src/renderer/src/components/composer/EffortPicker.test.ts @@ -3,11 +3,16 @@ import { describe, expect, it } from 'vitest' import { effortRowsForProvider, providerSupportsEffort } from './EffortPicker' describe('provider effort options', () => { - it('exposes Claude native max effort', () => { + it('exposes Claude Adaptive between off and the native effort levels', () => { expect(effortRowsForProvider('claude').map(row => row.id)).toEqual([ - 'off', 'low', 'medium', 'high', 'xhigh', 'max', + 'off', 'adaptive', 'low', 'medium', 'high', 'xhigh', 'max', ]) expect(providerSupportsEffort('claude', 'max')).toBe(true) + expect(providerSupportsEffort('claude', 'adaptive')).toBe(true) + }) + + it.each(['codex', 'crewcoder', 'grok', 'openrouter', 'ollama', 'opencode', 'pi', 'hermes'])('keeps Adaptive out of %s effort choices', provider => { + expect(providerSupportsEffort(provider, 'adaptive')).toBe(false) }) it('exposes Codex max and ultra without leaking ultra to Claude', () => { diff --git a/src/renderer/src/components/composer/EffortPicker.tsx b/src/renderer/src/components/composer/EffortPicker.tsx index f8aa651..eb0a02c 100644 --- a/src/renderer/src/components/composer/EffortPicker.tsx +++ b/src/renderer/src/components/composer/EffortPicker.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Icon } from '../ui/Icon' import { PickerSheet } from './PickerSheet' -export type EffortLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' +export type EffortLevel = 'off' | 'adaptive' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' export interface EffortRow { id: EffortLevel @@ -26,6 +26,7 @@ const CODEX_ROWS: EffortRow[] = [ const CLAUDE_ROWS: EffortRow[] = [ { id: 'off', label: 'off', sub: 'Off' }, + { id: 'adaptive', label: 'Adaptive', sub: 'Let Claude Code choose effort from your settings and model defaults' }, { id: 'low', label: 'Low', sub: 'Fast responses with lighter reasoning' }, { id: 'medium', label: 'Medium', sub: 'Balances speed and reasoning depth for everyday tasks' }, { id: 'high', label: 'High', sub: 'For intelligence-sensitive workloads' }, diff --git a/src/renderer/src/components/composer/MobileComposerMenus.tsx b/src/renderer/src/components/composer/MobileComposerMenus.tsx index 1fd4dc6..bec10da 100644 --- a/src/renderer/src/components/composer/MobileComposerMenus.tsx +++ b/src/renderer/src/components/composer/MobileComposerMenus.tsx @@ -19,7 +19,7 @@ const MODE_COPY: Record = { } const EFFORT_LABEL: Record = { - off: 'Off', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max', ultra: 'Ultra', + off: 'Off', adaptive: 'Adaptive', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max', ultra: 'Ultra', } function shortModel(id: string): string { diff --git a/src/renderer/src/components/composer/ModelRow.test.ts b/src/renderer/src/components/composer/ModelRow.test.ts index 0d06a22..c2410a7 100644 --- a/src/renderer/src/components/composer/ModelRow.test.ts +++ b/src/renderer/src/components/composer/ModelRow.test.ts @@ -14,10 +14,9 @@ describe('CrewCoder model-row control', () => { expect(crewCoderModesAvailable([crewcoder(true)], 'codex')).toBe(false) }) - it('shows native approval only for the concrete CrewCoder profile', () => { - expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder', 'crewcoder')).toBe(true) - expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder', 'plugin')).toBe(false) - expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder', undefined)).toBe(false) - expect(crewCoderApprovalAvailable([crewcoder(true)], 'codex', 'crewcoder')).toBe(false) + it('shows native approval for the active provider independently of its profile', () => { + expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder')).toBe(true) + expect(crewCoderApprovalAvailable([crewcoder(false)], 'crewcoder')).toBe(false) + expect(crewCoderApprovalAvailable([crewcoder(true)], 'codex')).toBe(false) }) }) diff --git a/src/renderer/src/components/composer/ModelRow.tsx b/src/renderer/src/components/composer/ModelRow.tsx index c3cd10a..bcf7713 100644 --- a/src/renderer/src/components/composer/ModelRow.tsx +++ b/src/renderer/src/components/composer/ModelRow.tsx @@ -47,7 +47,7 @@ interface ModelRowProps { } const EFFORT_LABEL: Record = { - off: 'off', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max', ultra: 'ultra', + off: 'off', adaptive: 'Adaptive', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max', ultra: 'ultra', } function shortModel(id: string): string { @@ -67,8 +67,8 @@ export function crewCoderModesAvailable(agents: AgentInfo[], activeAgentId: stri return activeAgentId === 'crewcoder' && agents.some(agent => agent.id === 'crewcoder' && agent.available) } -export function crewCoderApprovalAvailable(agents: AgentInfo[], activeAgentId: string, mode?: CrewCoderMode): boolean { - return crewCoderModesAvailable(agents, activeAgentId) && mode === 'crewcoder' +export function crewCoderApprovalAvailable(agents: AgentInfo[], activeAgentId: string): boolean { + return crewCoderModesAvailable(agents, activeAgentId) } export const ModelRow = forwardRef(function ModelRow({ @@ -123,7 +123,7 @@ export const ModelRow = forwardRef(function Model const active = agents.find(a => a.id === activeAgentId) ?? agents.find(a => a.available) const showCrewCoderModes = crewCoderModesAvailable(agents, activeAgentId) - const showCrewCoderApproval = crewCoderApprovalAvailable(agents, activeAgentId, crewcoderMode) + const showCrewCoderApproval = crewCoderApprovalAvailable(agents, activeAgentId) const handleSelectAgent = (id: string) => { // Start resolving the next provider immediately so the model picker is warm. diff --git a/src/renderer/src/components/promptBuilder/ActiveSkillsStrip.tsx b/src/renderer/src/components/promptBuilder/ActiveSkillsStrip.tsx index 8f72b22..cc38a5c 100644 --- a/src/renderer/src/components/promptBuilder/ActiveSkillsStrip.tsx +++ b/src/renderer/src/components/promptBuilder/ActiveSkillsStrip.tsx @@ -7,7 +7,7 @@ * - the skill name * - a delivery state dot: * pending (●, amber) → enabled but not yet sent to this session - * live (●, mint) → already delivered as a system block this session + * live (●, mint) → already delivered in a user message this session * * Click the chip to jump to the skill in Prompt Builder. Click the × to * disable the skill globally. @@ -40,7 +40,7 @@ export function ActiveSkillsStrip({ key={s.id} className={`skill-chip ${live ? 'live' : 'pending'}`} title={live - ? `${s.title} — injected as system prompt this session` + ? `${s.title} — sent with an earlier message this session` : `${s.title} — will inject on your next message`} > diff --git a/src/renderer/src/components/settings/SettingsScreen.tsx b/src/renderer/src/components/settings/SettingsScreen.tsx index 5689dfb..e167b0b 100644 --- a/src/renderer/src/components/settings/SettingsScreen.tsx +++ b/src/renderer/src/components/settings/SettingsScreen.tsx @@ -566,7 +566,7 @@ function BrainContinuitySection() {
Background Brain
-
Make this machine's Brain authoritative for workspaces, conversations, terminals, and agents. Desktop Background Brain and crewcode brain reuse the same saved machine enrollment; switching modes does not require enrolling your phone or machine again. Closing the desktop window leaves web access available; Stop Brain removes that availability.
+
Make this machine's Brain authoritative for workspaces, conversations, terminals, and agents. Enabling grants Brain the exact currently registered local project paths, but does not add execution scopes or expose SSH projects to the browser. Desktop Background Brain and crewcode brain reuse the same saved machine enrollment; switching modes does not require enrolling your phone or machine again. Closing the desktop window leaves web access available; Stop Brain removes that availability.
{status?.running ? 'running · desktop attached' : status?.enabled ? 'enabled · not reachable' : status?.enrolled ? 'ready to enable' : 'Hub enrollment required'}
{status?.hubBrowserOrigin ? ( <> @@ -1076,8 +1076,8 @@ function TypographySection({ state, set }: { state: SettingsState; set: SetSetti
-
Font size
-
Base size for monospace surfaces.
+
Chat & mono size
+
Message text, composer text, code chips, and other monospace surfaces.
set('fontSize', v)} />
diff --git a/src/renderer/src/components/settings/mobile-settings-layout.test.ts b/src/renderer/src/components/settings/mobile-settings-layout.test.ts index 8231080..c891d06 100644 --- a/src/renderer/src/components/settings/mobile-settings-layout.test.ts +++ b/src/renderer/src/components/settings/mobile-settings-layout.test.ts @@ -9,11 +9,11 @@ const mobileSettings = settingsStyles.slice(settingsStyles.indexOf('/* --------- const mobileApp = appStyles.slice(appStyles.indexOf('@media (max-width: 768px)')) describe('mobile Settings layout', () => { - it('removes the mobile titlebar while retaining the tab strip', () => { + it('removes the mobile titlebar and tab strip without leaving drawer offsets', () => { expect(mobileApp).toContain('.titlebar { display: none; }') - expect(mobileApp).toContain('.wintabs {') - expect(mobileApp).not.toContain('.wintabs { display: none; }') - expect(mobileApp).toContain('top: 40px;') + expect(mobileApp).toContain('.window-tabs { display: none; }') + expect(mobileApp).not.toContain('top: 40px;') + expect(mobileApp).toContain('inset: 0 0 calc(44px + env(safe-area-inset-bottom));') }) it('turns Settings into a stacked layout with horizontally scrollable categories', () => { diff --git a/src/renderer/src/components/settings/mobile-typography-settings.test.ts b/src/renderer/src/components/settings/mobile-typography-settings.test.ts new file mode 100644 index 0000000..562d6e0 --- /dev/null +++ b/src/renderer/src/components/settings/mobile-typography-settings.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(resolve(__dirname, '../../styles/styles.css'), 'utf8') +const settings = readFileSync(resolve(__dirname, 'SettingsScreen.tsx'), 'utf8') + +describe('mobile typography settings contract', () => { + it('keeps chat messages and the mobile composer bound to the live mono size', () => { + expect(styles).toContain('.agent .body { font-size: var(--mono-size, 12.5px); }') + expect(styles).toContain('font-size: max(16px, var(--mono-size, 13.5px)) !important') + expect(settings).toContain('Chat & mono size') + }) + + it('keeps the live editor size authoritative over CodeMirror injected themes', () => { + expect(styles).toMatch(/\.ed-cm-host \.cm-editor \{[\s\S]*?font-size: var\(--editor-size, 13px\) !important;/) + }) +}) diff --git a/src/renderer/src/components/ui/MobileWindowTabs.test.ts b/src/renderer/src/components/ui/MobileWindowTabs.test.ts new file mode 100644 index 0000000..e489569 --- /dev/null +++ b/src/renderer/src/components/ui/MobileWindowTabs.test.ts @@ -0,0 +1,55 @@ +import { createElement } from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { MobileWindowTabs } from './MobileWindowTabs' +import type { WindowTab } from './WindowTabs' + +vi.mock('./Icon', () => ({ Icon: () => null })) +vi.mock('./AgentActivityIndicator', () => ({ AgentActivityIndicator: ({ state }: { state?: string }) => createElement('span', { 'data-activity': state }) })) + +const tabs = [ + { id: 'chat', kind: 'chat', label: 'Fix scrolling', agentActivity: 'working' }, + { id: 'terminal', kind: 'terminal', label: 'Terminal' }, + { id: 'settings', kind: 'settings', label: 'Settings', pinned: true }, +] as WindowTab[] + +function render() { + const props = { tabs, activeId: 'chat', crewTabs: {}, onActivate: vi.fn(), onClose: vi.fn(), onNewTab: vi.fn(), pluginMenuItems: [{ id: 'plugin', pluginId: 'plugin', title: 'My plugin', kind: 'tab' as const, target: { pluginId: 'plugin', tab: 'home' } }], onPluginMenuItem: vi.fn() } + let view!: TestRenderer.ReactTestRenderer + act(() => { view = TestRenderer.create(createElement(MobileWindowTabs, props)) }) + return { view, props } +} + +describe('mobile tab switcher', () => { + it('marks the exact active tab, shows activity, and activates by identity', () => { + const { view, props } = render() + const selected = view.root.findByProps({ 'aria-current': 'page' }) + expect(selected.findByProps({ className: 'mobile-tab-label' }).children).toContain('Fix scrolling') + expect(view.root.findAllByProps({ 'data-activity': 'working' })).toHaveLength(1) + act(() => selected.props.onClick()) + expect(props.onActivate).toHaveBeenCalledWith('chat') + act(() => view.unmount()) + }) + + it('closes the selected row without activating it and keeps pinned tabs protected', () => { + const { view, props } = render() + expect(view.root.findAllByProps({ 'aria-label': 'Close Settings' })).toHaveLength(0) + act(() => view.root.findByProps({ 'aria-label': 'Close Terminal' }).props.onClick()) + expect(props.onClose).toHaveBeenCalledWith('terminal') + expect(props.onActivate).not.toHaveBeenCalled() + act(() => view.unmount()) + }) + + it('opens built-in and plugin destinations through the existing callbacks', () => { + const { view, props } = render() + act(() => view.root.findByProps({ className: 'mobile-tabs-new' }).props.onClick()) + const choices = view.root.findByProps({ 'aria-label': 'New tab choices' }) + const buttons = choices.findAllByType('button') + const terminal = buttons.find(button => button.findAllByType('span').some(span => span.children.includes('Terminal')))! + act(() => terminal.props.onClick()) + expect(props.onNewTab).toHaveBeenCalledWith('terminal') + act(() => buttons[buttons.length - 1].props.onClick()) + expect(props.onPluginMenuItem).toHaveBeenCalledWith(props.pluginMenuItems[0]) + act(() => view.unmount()) + }) +}) diff --git a/src/renderer/src/components/ui/MobileWindowTabs.tsx b/src/renderer/src/components/ui/MobileWindowTabs.tsx new file mode 100644 index 0000000..80db39f --- /dev/null +++ b/src/renderer/src/components/ui/MobileWindowTabs.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from 'react' +import { Icon } from './Icon' +import { AgentActivityIndicator } from './AgentActivityIndicator' +import { NEW_TAB_ACTIONS, TAB_ICONS, type WindowTab, type WindowTabPluginMenuItem } from './WindowTabs' +import type { BuiltinTabKind } from '../../types' +import type { CrewSessionState } from '../../orchestrator/crew-session' + +interface MobileWindowTabsProps { + tabs: WindowTab[] + activeId: string + crewTabs: Record + onActivate: (id: string) => void + onClose: (id: string) => void + onNewTab: (kind: BuiltinTabKind) => void + pluginMenuItems: WindowTabPluginMenuItem[] + onPluginMenuItem: (item: WindowTabPluginMenuItem) => void +} + +export function MobileWindowTabs({ tabs, activeId, crewTabs, onActivate, onClose, onNewTab, pluginMenuItems, onPluginMenuItem }: MobileWindowTabsProps) { + const [adding, setAdding] = useState(false) + const contentRef = useRef(null) + + useEffect(() => { + const sheet = contentRef.current?.closest('.sheet') + if (!sheet) return + const previous = document.activeElement as HTMLElement | null + const selected = sheet.querySelector('[aria-current="page"]') + ;(selected ?? sheet.querySelector('button'))?.focus() + selected?.scrollIntoView({ block: 'nearest' }) + // Keep keyboard focus inside this modal and return it to the dock trigger. + const trap = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + const buttons = [...sheet.querySelectorAll('button:not(:disabled)')] + const first = buttons[0] + const last = buttons[buttons.length - 1] + if (event.shiftKey && (document.activeElement === first || !sheet.contains(document.activeElement))) { + event.preventDefault() + last?.focus() + } else if (!event.shiftKey && (document.activeElement === last || !sheet.contains(document.activeElement))) { + event.preventDefault() + first?.focus() + } + } + document.addEventListener('keydown', trap) + return () => { + document.removeEventListener('keydown', trap) + if (previous?.isConnected) previous.focus() + } + }, []) + + const canvasTabs = tabs.filter(tab => tab.kind === 'canvas') + const visibleTabs = [...tabs.filter(tab => tab.pinned), ...tabs.filter(tab => !tab.pinned)] + + return ( +
+ + {adding ? ( +
+ {NEW_TAB_ACTIONS.map(item => ( + + ))} + {pluginMenuItems.map(item => ( + + ))} +
+ ) : ( +
+ {visibleTabs.length === 0 &&

No open tabs. Create one above.

} + {visibleTabs.map(tab => { + const crewState = crewTabs[tab.id] + const canvasNumber = canvasTabs.length > 1 ? canvasTabs.findIndex(item => item.id === tab.id) + 1 : null + const label = tab.kind === 'canvas' ? `Workbench Mode${canvasNumber ? ` ${canvasNumber}` : ''}` : tab.label + return ( +
+ + {!tab.pinned && } +
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/ui/WindowTabs.test.ts b/src/renderer/src/components/ui/WindowTabs.test.ts index e06372d..1dcf43d 100644 --- a/src/renderer/src/components/ui/WindowTabs.test.ts +++ b/src/renderer/src/components/ui/WindowTabs.test.ts @@ -5,30 +5,15 @@ import { describe, expect, it } from 'vitest' import { NEW_TAB_ACTIONS } from './WindowTabs' describe('WindowTabs new-tab menu', () => { - it('is mounted by App for desktop and mobile layouts', () => { + it('keeps desktop tabs and uses an on-demand sheet on mobile', () => { const app = readFileSync(join(__dirname, '../../App.tsx'), 'utf8') const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') - expect(app).toContain("
") - expect(app).toContain('') + expect(app).toContain(' { - const app = readFileSync(join(__dirname, '../../App.tsx'), 'utf8') - const tabs = readFileSync(join(__dirname, './WindowTabs.tsx'), 'utf8') - const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') - expect(app).toContain('useMobileWindowTabsAutoHide({') - expect(app).toContain('onNewTabMenuOpenChange={setWindowTabsMenuOpen}') - expect(tabs).toContain('onNewTabMenuOpenChange?.(newMenuOpen)') - expect(styles).toContain('.window-tabs.mobile-tabs-hidden {') - expect(styles).toContain('transform: translateY(-100%);') - }) - - it('replaces the mobile bottom nav with the scrollable tab strip and add menu', () => { - const shell = readFileSync(join(__dirname, './MobileShell.tsx'), 'utf8') - const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') - expect(shell).not.toContain('mobile-bottom-nav') - expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.tab-add-wrap \.tab-menu/) + expect(styles).toContain('.window-tabs { display: none; }') }) it('offers the built-in control, studio, and Git workspace pages', () => { diff --git a/src/renderer/src/components/ui/WindowTabs.tsx b/src/renderer/src/components/ui/WindowTabs.tsx index 31c4841..5657a1c 100644 --- a/src/renderer/src/components/ui/WindowTabs.tsx +++ b/src/renderer/src/components/ui/WindowTabs.tsx @@ -18,7 +18,7 @@ export interface WindowTabPluginMenuItem { target: { pluginId: string; sidebarPanel?: string; tab?: string; command?: string } } -interface WindowTab extends Tab { +export interface WindowTab extends Tab { displayIconProviderId?: string agentActivity?: AgentActivityState } @@ -50,7 +50,7 @@ interface WindowTabsProps { onNewTabMenuOpenChange?: (open: boolean) => void } -const TAB_ICONS: Record = { +export const TAB_ICONS: Record = { chat: 'threads', crew: 'crew', canvas: 'grid', git: 'gitBranch', code: 'code', writer: 'edit', terminal: 'terminal', browser: 'globe', settings: 'settings', plugins: 'plug', prompts: 'sparkle', mission: 'grid', archive: 'archive', plugin: 'grid', } diff --git a/src/renderer/src/components/workspaces/WorkspaceDock.test.ts b/src/renderer/src/components/workspaces/WorkspaceDock.test.ts new file mode 100644 index 0000000..acfb5da --- /dev/null +++ b/src/renderer/src/components/workspaces/WorkspaceDock.test.ts @@ -0,0 +1,33 @@ +import { createElement } from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceDock } from './WorkspaceDock' + +vi.mock('../ui/Icon', () => ({ Icon: () => null })) +afterEach(() => vi.unstubAllGlobals()) + +describe('workspace dock tab switcher', () => { + it('opens mobile tabs without also opening the workspace drawer', () => { + vi.stubGlobal('window', {}) + const onToggle = vi.fn() + const onOpenTabs = vi.fn() + const stopPropagation = vi.fn() + let view!: TestRenderer.ReactTestRenderer + act(() => { view = TestRenderer.create(createElement(WorkspaceDock, { open: false, onToggle, onOpenTabs, tabsOpen: true, tabCount: 3 })) }) + const trigger = view.root.findByProps({ 'aria-label': 'Tabs, 3 open' }) + expect(trigger.props['aria-expanded']).toBe(true) + act(() => trigger.props.onClick({ stopPropagation })) + expect(stopPropagation).toHaveBeenCalledOnce() + expect(onOpenTabs).toHaveBeenCalledOnce() + expect(onToggle).not.toHaveBeenCalled() + act(() => view.unmount()) + }) + + it('does not add a tab button to the desktop dock', () => { + vi.stubGlobal('window', {}) + let view!: TestRenderer.ReactTestRenderer + act(() => { view = TestRenderer.create(createElement(WorkspaceDock, { open: false, onToggle: vi.fn() })) }) + expect(view.root.findAllByProps({ className: 'ws-dock-tabs' })).toHaveLength(0) + act(() => view.unmount()) + }) +}) diff --git a/src/renderer/src/components/workspaces/WorkspaceDock.tsx b/src/renderer/src/components/workspaces/WorkspaceDock.tsx index 2b2b3f8..46f24b4 100644 --- a/src/renderer/src/components/workspaces/WorkspaceDock.tsx +++ b/src/renderer/src/components/workspaces/WorkspaceDock.tsx @@ -324,6 +324,9 @@ interface WorkspaceDockProps { open: boolean activeWs?: Workspace onToggle: () => void + onOpenTabs?: () => void + tabsOpen?: boolean + tabCount?: number activeWorktree?: Worktree pluginStatusItems?: RegisteredPluginStatusItem[] onPluginStatusItem?: (item: RegisteredPluginStatusItem) => void @@ -361,6 +364,7 @@ export function WorkspaceDock({ rateLimitStatus = 'ok', externalDirectoryCount = 0, onManageExternalDirectories, + onOpenTabs, tabsOpen = false, tabCount = 0, }: WorkspaceDockProps) { const sshConnId = connIdForWorkspace(activeWs) const [statuses, setStatuses] = useState>({}) @@ -402,6 +406,16 @@ export function WorkspaceDock({ )} + {onOpenTabs && } {pluginStatusItems.map(item => (