From eff3acc9dfc8dbd523e9ad93027a7bc9bfe9408f Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Sun, 30 Aug 2026 16:43:41 -0400 Subject: [PATCH 01/10] feat: implement file copy and move functionality with unique naming - Refactored file system IPC handlers to delegate copy and move operations to a service layer. - Added unique naming logic for file copies to prevent collisions. - Introduced discard functionality in Git service to restore changes or remove untracked files. - Enhanced file tree component to support cut, copy, and paste operations with clipboard management. - Implemented context menus for file operations in the Git page and file tree. --- AGENTS.md | 3 + docs/code-editor.md | 1 + docs/current-state.md | 2 +- docs/git-workspace.md | 5 +- docs/remote-ssh-workspaces.md | 5 ++ docs/web-remote-access.md | 70 ++++++++------- src/main/agents/crewcoder-bridge.test.ts | 16 ++++ src/main/agents/crewcoder-bridge.ts | 22 +++-- src/main/filesystem-service.test.ts | 26 ++++++ src/main/filesystem-service.ts | 88 ++++++++++++++++++- src/main/fs-copy-name.test.ts | 13 +++ src/main/fs-copy-name.ts | 15 ++++ src/main/fs.ts | 39 ++------ src/main/git-discard.test.ts | 21 +++++ src/main/git-discard.ts | 15 ++++ src/main/git-service.ts | 2 + src/main/git.ts | 16 ++++ src/main/remote-access-server.ts | 7 ++ src/main/remote/remote-fs.ts | 38 ++++++-- src/preload/index.ts | 4 +- .../src/components/editor/FileTree.tsx | 57 +++++++++++- .../editor/file-tree-clipboard.test.ts | 32 +++++++ .../components/editor/file-tree-clipboard.ts | 26 ++++++ src/renderer/src/components/git/GitPage.tsx | 3 +- .../src/components/git/GitPageChanges.tsx | 39 ++++++-- src/renderer/src/hooks/useGitSidebar.ts | 1 + src/renderer/src/runtime/web-rpc-client.ts | 3 + src/renderer/src/styles/styles.css | 10 +++ src/renderer/src/types/index.ts | 3 +- 29 files changed, 488 insertions(+), 94 deletions(-) create mode 100644 src/main/fs-copy-name.test.ts create mode 100644 src/main/fs-copy-name.ts create mode 100644 src/main/git-discard.test.ts create mode 100644 src/main/git-discard.ts create mode 100644 src/renderer/src/components/editor/file-tree-clipboard.test.ts create mode 100644 src/renderer/src/components/editor/file-tree-clipboard.ts diff --git a/AGENTS.md b/AGENTS.md index 7ecafdd..609f82f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,9 @@ This file provides guidance to models when working with code in this repository. Update corresponding Docs in [CrewCode Docs](/docs/), and [AGENTS.md](/AGENTS.md/) when major changes were made and or every time i add a feature. Create or update docs file for it +Git Workspace changed-file rows support stage/unstage controls and a context +menu for stage, stage-all, unstage, and explicitly confirmed discard actions. + ## What is CrewCode? CrewCode is a desktop ACE (Agent Coding Environment) GUI built with Electron + React + TypeScript. It lets developers run a *crew* of AI coding agents (Claude Code, Codex, OpenCode, etc.) in parallel across local git worktrees, each in its own workspace with a chat thread, embedded terminal panes, and a code/markdown editor — all in one frameless native-feeling window. diff --git a/docs/code-editor.md b/docs/code-editor.md index f0cf3c3..99b9d1f 100644 --- a/docs/code-editor.md +++ b/docs/code-editor.md @@ -5,6 +5,7 @@ CrewCode's code editor uses a fork of CodeMirror 6 for the active editing surfac ## Current foundation - `src/renderer/src/components/editor/CodeEditor.tsx` owns the surrounding product UI: tabs, file tree, save/format actions, disk-change conflict handling, plugin editor actions, and search-result jumps. +- The file-tree context menu **Cut** / **Copy** / **Paste** a file or folder through an in-session clipboard (not the OS clipboard; **Copy path** still does that). Paste into the folder under the cursor, a file's parent, or the tree root. **Copy** then paste uses sandboxed `fs.copyFile` and suffixes name collisions with `copy`. **Cut** then paste uses sandboxed `fs.move`, dims the source row until the move lands, refuses the current parent and pasting a folder into itself, and clears the clipboard only after a successful move. **Duplicate** still creates a sibling copy in place. The clipboard is workspace-scoped and clears when the tree root changes. SSH workspaces use the remote copy/move paths. - Git Sidebar changed-file rows open the active worktree's patch in the editor's existing `PierreDiff` review surface. The Settings-selected default branch is the comparison ref when configured; closing the review returns to the prior editor state without changing the checked-out branch. - `src/renderer/src/components/editor/CrewCodeMirrorEditor.tsx` owns the live editing surface. - CodeMirror is intentionally kept below `CodeEditor` so high-frequency typing, selection, autocomplete, and scroll state do not force broad React/App re-renders. diff --git a/docs/current-state.md b/docs/current-state.md index 1e14c63..80f61e9 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -127,7 +127,7 @@ Writer DOCX/PDF support is a conversion workflow, not native binary editing. Per ## Code Editor -The code editor's active editing surface is CodeMirror 6 (`CrewCodeMirrorEditor`) wrapped by `CodeEditor`, which still owns tabs, file-tree UI, save/format, disk-conflict prompts, plugin editor actions, and its Pierre diff review surface. Clicking a changed file in Git Sidebar opens the active worktree's single-file patch in that editor diff surface; it must not diff the workspace root when a worktree is selected. Keep high-frequency editor document/selection/autocomplete state inside CodeMirror instead of lifting it into `App.tsx`. Direct `@codemirror/*` dependencies use local `file:` links to the independently cloned package repositories under `packages/crew-codemirror`; rebuild those sources with `npm run codemirror:build`, and never run the upstream `codemirror:install` bootstrap over uncommitted package edits because it hard-resets every child repository. Editor themes come from `packages/crew-codemirror/theme-library`, use the checked IDs in `src/shared/editor-theme-types.ts`, and must reconfigure through CodeMirror's theme `Compartment` so switching palettes never destroys document, history, selection, scroll, or LSP state. Editor file/tree icons use the vendored Bearded Icons assets and mapping under `src/renderer/src/assets/bearded-icons`; preserve its GPL-3.0 license/attribution, prefer exact filename then compound-extension mappings, and never runtime-reference the gitignored `.crewcode/` source. The FileTree Outline must derive active-document symbols inside CodeMirror: prefer hierarchical LSP document symbols for TS/JS, use the bounded local fallback for supported non-LSP languages, reject stale responses by document identity, and keep symbol extraction out of `App.tsx`. TypeScript/JavaScript intelligence uses one shared `@codemirror/lsp-client` and `typescript-language-server` process per workspace. Keep JSON-RPC framing and process ownership in the main process, sanitize LSP Markdown, refuse definition/problem paths outside the workspace sandbox, and launch SSH language servers remotely rather than reading remote projects through local-only paths; remote hosts must provide TypeScript and `typescript-language-server`, and CrewCode must not install them automatically. The Problems and workspace-search indexes must remain bounded. Code Actions currently apply only validated, non-overlapping edits to an unchanged active document; fail closed on commands, stale responses, malformed ranges, and multi-file edits. LSP rename and workspace replace are preview-first multi-file flows: block affected dirty tabs, reject outside-workspace/stale/malformed edits, verify file snapshots immediately before writing, and roll back completed writes after a later failure. Preserve the same guarantees over SSH. Editor AI completions use a dedicated provider/model setting and disposable `agent:completion` bridges: bounded context, `toolPolicy: 'read-only'`, `thinking: 'off'`, no persisted conversation/resume state, 20-second timeout, cancellation on every stale edit, and built-in providers only (no plugins/Copilot API). Ghost text must never show model reasoning: disabling provider reasoning is not sufficient, because models that inline `` blocks into the content stream arrive as ordinary `text_delta`. Every completion route must normalize through the shared `src/main/agents/completion-text.ts` (strip reasoning blocks, reject unterminated ones, then unwrap one fence) — do not re-inline a per-provider copy of that logic. Completion-only hosted APIs are deliberately distinct from chat providers: OpenCode Go uses its OpenAI-compatible bearer-key endpoint and OpenRouter reuses its API route; both must remain ephemeral and never write completion content to conversation history. See `docs/code-editor.md`. +The code editor's active editing surface is CodeMirror 6 (`CrewCodeMirrorEditor`) wrapped by `CodeEditor`, which still owns tabs, file-tree UI, save/format, disk-conflict prompts, plugin editor actions, and its Pierre diff review surface. File-tree **Cut** / **Copy** / **Paste** use a workspace-scoped in-session clipboard (not the OS clipboard; **Copy path** still does that). Copy pastes through sandboxed `fs.copyFile` with a `copy` suffix on collisions; cut pastes through sandboxed `fs.move`, dims the source until it lands, and refuses the current parent or a folder into itself. Keep **Duplicate** as a same-directory sibling copy. Clicking a changed file in Git Sidebar opens the active worktree's single-file patch in that editor diff surface; it must not diff the workspace root when a worktree is selected. Keep high-frequency editor document/selection/autocomplete state inside CodeMirror instead of lifting it into `App.tsx`. Direct `@codemirror/*` dependencies use local `file:` links to the independently cloned package repositories under `packages/crew-codemirror`; rebuild those sources with `npm run codemirror:build`, and never run the upstream `codemirror:install` bootstrap over uncommitted package edits because it hard-resets every child repository. Editor themes come from `packages/crew-codemirror/theme-library`, use the checked IDs in `src/shared/editor-theme-types.ts`, and must reconfigure through CodeMirror's theme `Compartment` so switching palettes never destroys document, history, selection, scroll, or LSP state. Editor file/tree icons use the vendored Bearded Icons assets and mapping under `src/renderer/src/assets/bearded-icons`; preserve its GPL-3.0 license/attribution, prefer exact filename then compound-extension mappings, and never runtime-reference the gitignored `.crewcode/` source. The FileTree Outline must derive active-document symbols inside CodeMirror: prefer hierarchical LSP document symbols for TS/JS, use the bounded local fallback for supported non-LSP languages, reject stale responses by document identity, and keep symbol extraction out of `App.tsx`. TypeScript/JavaScript intelligence uses one shared `@codemirror/lsp-client` and `typescript-language-server` process per workspace. Keep JSON-RPC framing and process ownership in the main process, sanitize LSP Markdown, refuse definition/problem paths outside the workspace sandbox, and launch SSH language servers remotely rather than reading remote projects through local-only paths; remote hosts must provide TypeScript and `typescript-language-server`, and CrewCode must not install them automatically. The Problems and workspace-search indexes must remain bounded. Code Actions currently apply only validated, non-overlapping edits to an unchanged active document; fail closed on commands, stale responses, malformed ranges, and multi-file edits. LSP rename and workspace replace are preview-first multi-file flows: block affected dirty tabs, reject outside-workspace/stale/malformed edits, verify file snapshots immediately before writing, and roll back completed writes after a later failure. Preserve the same guarantees over SSH. Editor AI completions use a dedicated provider/model setting and disposable `agent:completion` bridges: bounded context, `toolPolicy: 'read-only'`, `thinking: 'off'`, no persisted conversation/resume state, 20-second timeout, cancellation on every stale edit, and built-in providers only (no plugins/Copilot API). Ghost text must never show model reasoning: disabling provider reasoning is not sufficient, because models that inline `` blocks into the content stream arrive as ordinary `text_delta`. Every completion route must normalize through the shared `src/main/agents/completion-text.ts` (strip reasoning blocks, reject unterminated ones, then unwrap one fence) — do not re-inline a per-provider copy of that logic. Completion-only hosted APIs are deliberately distinct from chat providers: OpenCode Go uses its OpenAI-compatible bearer-key endpoint and OpenRouter reuses its API route; both must remain ephemeral and never write completion content to conversation history. See `docs/code-editor.md`. ## Workbench Mode diff --git a/docs/git-workspace.md b/docs/git-workspace.md index 0f990cd..0085fbb 100644 --- a/docs/git-workspace.md +++ b/docs/git-workspace.md @@ -41,7 +41,10 @@ available only for real working-tree changes. - **Overview cards** — quick counts for changes and recent history. - **Changes** — files changed against the configured default branch (or the working tree when none is configured), with staging for local changes and - Pierre diff review. + Pierre diff review. Staged rows have a minus control to unstage them; right- + click a row for **stage changes**, **stage all changes**, **unstage changes**, + or **discard changes**. Discard restores tracked files to `HEAD` (including + staged changes) and removes untracked files, so it cannot be undone. - **Commit** — commit message + commit action, including signing support. - **Sidebar sections** — history, branches, and the remaining Git Sidebar sections render on the right (with the commit/changes sections hidden, since diff --git a/docs/remote-ssh-workspaces.md b/docs/remote-ssh-workspaces.md index 2f54d61..d234dae 100644 --- a/docs/remote-ssh-workspaces.md +++ b/docs/remote-ssh-workspaces.md @@ -61,6 +61,11 @@ the connection** (possible MITM or server rekey). | Code intelligence | the TypeScript language server is launched **on the remote**; the remote host must have `typescript` and `typescript-language-server` installed — CrewCode does not install them | | Writer file watching | bounded polling (remote filesystem events are unavailable) | +CrewCode advertises ACP text-file methods for both local and remote CrewCoder +sessions. Remote custody is negotiated separately through explicit initialize +metadata; the presence of file methods alone does not disable provider-native +tools or reject providers in an ordinary local chat. + ## Limitations - Plugins are denied access to remote workspaces (plugin API v0 is local-only). diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index 7432814..c5f6370 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -87,10 +87,10 @@ connections. Hub relay provides a stronger multi-machine topology, not a blanket security upgrade: it has more moving parts and remains a preview with the limitations listed below. -Example direct server: +Example direct server from a built source checkout: ```bash -crewcode serve \ +node bin/crewcode-server.mjs serve \ --host 127.0.0.1 \ --workspace-root /path/to/projects ``` @@ -100,10 +100,10 @@ Example Hub deployment: ```bash # On the persistent Hub host (VPS, NAS, or always-on desktop): # Identity/relay only — recommended when this box should not execute agents: -crewcode hub --host 0.0.0.0 --public-origin https://your-hub.example +node bin/crewcode-server.mjs hub --host 0.0.0.0 --public-origin https://your-hub.example # Same host should also appear as a machine (sibling Brain, not one process): -crewcode hub --local-brain \ +node bin/crewcode-server.mjs hub --local-brain \ --host 0.0.0.0 \ --public-origin https://your-hub.example \ --workspace-root /path/to/projects \ @@ -113,10 +113,10 @@ crewcode hub --local-brain \ --allow-scope agent # On each additional development machine, enroll once: -crewcode enroll --hub https://your-hub.example +node bin/crewcode-server.mjs enroll --hub https://your-hub.example # Then run the outbound Brain with explicit local authority: -crewcode brain \ +node bin/crewcode-server.mjs brain \ --workspace-root /path/to/projects \ --allow-scope workspace:read \ --allow-scope workspace:write \ @@ -424,19 +424,22 @@ AuditEvent(id, user_id?, machine_id?, browser_session_id?, type, created_at, met Implemented direct-server commands: ```bash -npx crewcode@latest -npx crewcode serve --host 127.0.0.1 -npx crewcode serve --host 0.0.0.0 --public-origin https://your-hub.example +npm run serve +npm run serve -- --host 127.0.0.1 +npm run serve -- --host 0.0.0.0 --public-origin https://your-hub.example ``` +These commands currently run from a source checkout. CrewCode is not distributed +as an npm package. + Implemented self-hosted Hub and mobile QR commands: ```bash -crewcode hub -crewcode hub --local-brain --workspace-root ~/developing --allow-scope agent -crewcode hub mobile --tailscale -crewcode hub mobile --public-origin https://your-hub.example -crewcode hub --host 0.0.0.0 --public-origin https://your-hub.example +node bin/crewcode-server.mjs hub +node bin/crewcode-server.mjs hub --local-brain --workspace-root ~/developing --allow-scope agent +node bin/crewcode-server.mjs hub mobile --tailscale +node bin/crewcode-server.mjs hub mobile --public-origin https://your-hub.example +node bin/crewcode-server.mjs hub --host 0.0.0.0 --public-origin https://your-hub.example ``` `hub mobile --tailscale` requires a connected Tailscale client, MagicDNS, and HTTPS @@ -478,7 +481,7 @@ still owner-only on disk; Hub identity still cannot widen Brain scopes. After signing in on the phone, run this on every additional machine: ```bash -crewcode enroll --hub https://your-hub.example +node bin/crewcode-server.mjs enroll --hub https://your-hub.example ``` The PC generates its Ed25519 identity locally, prints a short `XXXX-XXXX` comparison @@ -492,7 +495,7 @@ legacy `--token` path remains for controlled automation but is no longer the def Then start the relay: ```bash -crewcode brain +node bin/crewcode-server.mjs brain ``` Enrollment creates an Ed25519 machine identity plus a random bearer credential in @@ -509,7 +512,7 @@ Remote authority is disabled by default. Enable only explicit Brain-local roots scopes, for example: ```bash -crewcode brain \ +node bin/crewcode-server.mjs brain \ --workspace-root ~/developing \ --allow-scope workspace:read \ --allow-scope workspace:write \ @@ -538,25 +541,30 @@ crewcode hub revoke crewcode brain logout ``` -The initial CLI distribution is implemented. From a checkout, run `npm run serve`; -from a published package, run `npx crewcode@latest` or `crewcode serve`. It -builds/serves the shared renderer, defaults to loopback, prints a single-use pairing -URL, resolves installed provider CLIs without Electron, and shuts down cleanly on -SIGINT/SIGTERM. The direct-auth CLI and remaining machine-management commands above -remain planned. Enrollment, dashboard revocation, machine selection, and shared -CrewCode workspace-client launch through the encrypted Hub relay are implemented. +The initial CLI implementation is available from a source checkout through +`npm run serve` or `node bin/crewcode-server.mjs ` after `npm run build`. +It builds/serves the shared renderer, defaults to loopback, prints a single-use +pairing URL, resolves installed provider CLIs without Electron, and shuts down +cleanly on SIGINT/SIGTERM. The direct-auth CLI and remaining machine-management +commands above remain planned. Enrollment, dashboard revocation, machine selection, +and shared CrewCode workspace-client launch through the encrypted Hub relay are +implemented. ## Current backend extraction `WorkspaceService` owns persisted workspace listing and mutations, project creation, cloning, and remote workspace registration without importing Electron. -`FilesystemService` owns sandboxed directory listing, text reads/writes, and file -discovery, including the existing SSH routing. Network filesystem RPC also -rejects roots absent from the server workspace store, preventing a browser from -substituting `/` or another arbitrary host path. `workspaceStore.ts` and `fs.ts` -are now Electron transport adapters for those operations. Native folder pickers, -formatting, and destructive filesystem mutations remain in the Electron adapter -until their browser API and validation contracts are added. +`FilesystemService` owns sandboxed directory listing, text reads/writes, mkdir, +rename, delete, copy, and file discovery, including the existing SSH routing for +reads. Network filesystem RPC also rejects roots absent from the server workspace +store, preventing a browser from substituting `/` or another arbitrary host path. +`workspaceStore.ts` and `fs.ts` are now Electron transport adapters for those +operations. Browser file-tree copy/paste uses `fs.copyFile` (same-dir duplicate +when `destDirRel` is omitted, otherwise copy into that folder, with `''` meaning +the workspace root). Cut/paste uses `fs.move` into the destination folder. +Native folder pickers remain in the Electron adapter. SSH roots still refuse +copy/move/mkdir/rename/delete over web access; Electron SSH uses the remote +copy and move paths. Hub-relayed attachment tunneling uses ordered 256 KiB chunks inside the existing browser-to-Brain encrypted RPC tunnel. The Hub sees only bounded ciphertext frames. diff --git a/src/main/agents/crewcoder-bridge.test.ts b/src/main/agents/crewcoder-bridge.test.ts index b96ccde..0b6a8d0 100644 --- a/src/main/agents/crewcoder-bridge.test.ts +++ b/src/main/agents/crewcoder-bridge.test.ts @@ -9,6 +9,7 @@ vi.mock('./agent-spawn', () => ({ spawnAgentProcess })) import { CREWCODER_PROMPT_INACTIVITY_TIMEOUT_MS, createCrewCoderBridge, + crewCoderInitializeParams, crewCoderAcpErrorMessage, createCrewCoderToolProjectionState, crewCoderEventsFromUpdate, @@ -17,6 +18,21 @@ import { crewCoderUsageFromPromptResult, } from './crewcoder-bridge' +describe('CrewCoder filesystem custody handshake', () => { + it('keeps local ACP file capabilities separate from virtual custody', () => { + expect(crewCoderInitializeParams(false)).toEqual(expect.objectContaining({ + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false }, + _meta: { 'crewcode/virtualFilesystem': false }, + })) + }) + + it('marks the filesystem virtual only for a remote process', () => { + expect(crewCoderInitializeParams(true)).toEqual(expect.objectContaining({ + _meta: { 'crewcode/virtualFilesystem': true }, + })) + }) +}) + afterEach(() => { vi.useRealTimers() spawnAgentProcess.mockReset() diff --git a/src/main/agents/crewcoder-bridge.ts b/src/main/agents/crewcoder-bridge.ts index 7367d19..230249f 100644 --- a/src/main/agents/crewcoder-bridge.ts +++ b/src/main/agents/crewcoder-bridge.ts @@ -398,6 +398,20 @@ function splitModel(model: string | undefined): { provider?: string; model?: str return { provider: value.slice(0, separator), model: value.slice(separator + 1) } } +export function crewCoderInitializeParams(remote: boolean): Record { + return { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: false, + }, + // File capabilities are advertised for local chats too. Keep virtual + // custody as a separate, explicit signal so providers are restricted only + // when those methods proxy a filesystem outside the agent process host. + _meta: { 'crewcode/virtualFilesystem': remote }, + } +} + export async function createCrewCoderBridge( crewCoderPath: string, opts: BridgeStartOpts, @@ -791,13 +805,7 @@ export async function createCrewCoderBridge( }) try { - await request('initialize', { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - terminal: false, - }, - }) + await request('initialize', crewCoderInitializeParams(remote)) let resumed = false if (opts.resumeSessionId) { diff --git a/src/main/filesystem-service.test.ts b/src/main/filesystem-service.test.ts index 3f2ea4c..ca21dbf 100644 --- a/src/main/filesystem-service.test.ts +++ b/src/main/filesystem-service.test.ts @@ -40,6 +40,32 @@ describe('FilesystemService', () => { expect(service.readDataUrl(root, '../pixel.png')).toEqual({ error: 'path escapes root' }) }) + it('copies files and folders without escaping the workspace', () => { + const { root, service } = fixture() + mkdirSync(join(root, 'src')) + mkdirSync(join(root, 'dest')) + writeFileSync(join(root, 'src', 'index.ts'), 'export {}') + writeFileSync(join(root, 'readme.md'), '# hi') + mkdirSync(join(root, 'src', 'lib')) + writeFileSync(join(root, 'src', 'lib', 'util.ts'), 'ok') + + expect(service.copyFile(root, 'readme.md')).toMatchObject({ ok: true, rel: 'readme copy.md' }) + expect(readFileSync(join(root, 'readme copy.md'), 'utf8')).toBe('# hi') + expect(service.copyFile(root, 'src/index.ts', 'dest')).toMatchObject({ ok: true, rel: join('dest', 'index.ts') }) + expect(readFileSync(join(root, 'dest', 'index.ts'), 'utf8')).toBe('export {}') + expect(service.copyFile(root, 'src/index.ts', 'dest')).toMatchObject({ ok: true, rel: join('dest', 'index copy.ts') }) + expect(service.copyFile(root, 'src/lib', 'dest')).toMatchObject({ ok: true, rel: join('dest', 'lib') }) + expect(readFileSync(join(root, 'dest', 'lib', 'util.ts'), 'utf8')).toBe('ok') + expect(service.copyFile(root, 'src/lib', 'src/lib')).toEqual({ error: 'cannot copy a folder into itself' }) + expect(service.move(root, 'dest/index.ts', '')).toMatchObject({ ok: true, rel: 'index.ts' }) + expect(existsSync(join(root, 'dest', 'index.ts'))).toBe(false) + expect(readFileSync(join(root, 'index.ts'), 'utf8')).toBe('export {}') + expect(service.move(root, 'src/lib', 'src/lib')).toEqual({ error: 'cannot move a folder into itself' }) + expect(service.move(root, '../secret', 'dest')).toEqual({ error: 'source escapes root' }) + expect(service.copyFile(root, '../secret')).toEqual({ error: 'path escapes root' }) + expect(service.copyFile(root, 'readme.md', '../')).toEqual({ error: 'destination escapes root' }) + }) + it('lists directories while hiding ignored dependency trees', () => { const { root, service } = fixture() mkdirSync(join(root, 'node_modules')) diff --git a/src/main/filesystem-service.ts b/src/main/filesystem-service.ts index 3efea52..29323f1 100644 --- a/src/main/filesystem-service.ts +++ b/src/main/filesystem-service.ts @@ -1,7 +1,8 @@ import { execFile } from 'child_process' import { basename, dirname, extname, isAbsolute, join, normalize, relative, sep } from 'path' -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs' +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs' import { IGNORE, MAX_FILE_BYTES } from './fs-constants' +import { uniqueCopyName } from './fs-copy-name' import { isRemoteRoot } from './remote/ssh-target' import { remoteListFiles, remoteReadDir, remoteReadFile, remoteWriteFile } from './remote/remote-fs' @@ -136,6 +137,91 @@ export class FilesystemService { catch (error) { return { error: (error as Error).message } } } + move(root: string, srcRel: string, destDirRel: string): { ok?: boolean; rel?: string; error?: string } { + if (isRemoteRoot(root)) return { error: 'move unavailable on remote workspaces over web access' } + if (!root || !isAbsolute(root)) return { error: 'absolute root required' } + if (!srcRel || srcRel === '.' || srcRel === '..') return { error: 'source missing' } + const source = join(root, srcRel) + if (!safeUnder(root, source) || source === normalize(root)) return { error: 'source escapes root' } + if (!existsSync(source)) return { error: 'source missing' } + let sourceStat + try { sourceStat = statSync(source) } catch { return { error: 'stat failed' } } + + const destDir = destDirRel ? join(root, destDirRel) : root + if (!safeUnder(root, destDir)) return { error: 'destination escapes root' } + if (!existsSync(destDir)) return { error: 'destination missing' } + let destStat + try { destStat = statSync(destDir) } catch { return { error: 'destination missing' } } + if (!destStat.isDirectory()) return { error: 'destination is not a directory' } + + if (sourceStat.isDirectory()) { + const sourceNorm = normalize(source) + const destNorm = normalize(destDir) + if (destNorm === sourceNorm || destNorm.startsWith(sourceNorm + sep)) { + return { error: 'cannot move a folder into itself' } + } + } + + const destination = join(destDir, basename(source)) + if (!safeUnder(root, destination) || destination === normalize(root)) return { error: 'destination escapes root' } + if (existsSync(destination)) return { error: `${basename(source)} already exists there` } + try { + renameSync(source, destination) + return { ok: true, rel: relative(root, destination) } + } catch (error) { + return { error: (error as Error).message } + } + } + + /** + * Copy a file or folder under `root`. + * Omit `destDirRel` to duplicate beside the source; pass `''` for the workspace root. + */ + copyFile(root: string, sub: string, destDirRel?: string): { ok?: boolean; rel?: string; error?: string } { + if (isRemoteRoot(root)) return { error: 'copy unavailable on remote workspaces over web access' } + if (!root || !isAbsolute(root)) return { error: 'absolute root required' } + if (!sub || sub === '.' || sub === '..') return { error: 'path missing' } + const source = join(root, sub) + if (!safeUnder(root, source) || source === normalize(root)) return { error: 'path escapes root' } + if (!existsSync(source)) return { error: 'path missing' } + let sourceStat + try { sourceStat = statSync(source) } catch { return { error: 'stat failed' } } + + const destDir = destDirRel === undefined + ? dirname(source) + : destDirRel + ? join(root, destDirRel) + : root + if (!safeUnder(root, destDir)) return { error: 'destination escapes root' } + if (!existsSync(destDir)) return { error: 'destination missing' } + let destStat + try { destStat = statSync(destDir) } catch { return { error: 'destination missing' } } + if (!destStat.isDirectory()) return { error: 'destination is not a directory' } + + if (sourceStat.isDirectory()) { + const sourceNorm = normalize(source) + const destNorm = normalize(destDir) + if (destNorm === sourceNorm || destNorm.startsWith(sourceNorm + sep)) { + return { error: 'cannot copy a folder into itself' } + } + } + + let name: string + try { + name = uniqueCopyName(basename(source), candidate => existsSync(join(destDir, candidate))) + } catch (error) { + return { error: (error as Error).message } + } + const destination = join(destDir, name) + if (!safeUnder(root, destination) || destination === normalize(root)) return { error: 'destination escapes root' } + try { + cpSync(source, destination, { recursive: true, errorOnExist: true, force: false }) + return { ok: true, rel: relative(root, destination) } + } catch (error) { + return { error: (error as Error).message } + } + } + async listFiles(root: string): Promise<{ files?: string[]; error?: string }> { if (isRemoteRoot(root)) return remoteListFiles(root) if (!root || !isAbsolute(root)) return { error: 'absolute root required' } diff --git a/src/main/fs-copy-name.test.ts b/src/main/fs-copy-name.test.ts new file mode 100644 index 0000000..90794f3 --- /dev/null +++ b/src/main/fs-copy-name.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { uniqueCopyName } from './fs-copy-name' + +describe('uniqueCopyName', () => { + it('keeps the original name when it is free', () => { + expect(uniqueCopyName('index.ts', () => false)).toBe('index.ts') + }) + + it('adds numbered copy suffixes after a collision', () => { + const taken = new Set(['index.ts', 'index copy.ts']) + expect(uniqueCopyName('index.ts', name => taken.has(name))).toBe('index copy 2.ts') + }) +}) diff --git a/src/main/fs-copy-name.ts b/src/main/fs-copy-name.ts new file mode 100644 index 0000000..1953710 --- /dev/null +++ b/src/main/fs-copy-name.ts @@ -0,0 +1,15 @@ +import { basename, extname } from 'path' + +/** Collision-safe copy name: `foo.ts` → `foo copy.ts` → `foo copy 2.ts`. */ +export function uniqueCopyName(fileName: string, taken: (candidate: string) => boolean): string { + if (!taken(fileName)) return fileName + const ext = extname(fileName) + const stem = basename(fileName, ext) + let n = 1 + while (n < 10_000) { + const candidate = n === 1 ? `${stem} copy${ext}` : `${stem} copy ${n}${ext}` + if (!taken(candidate)) return candidate + n++ + } + throw new Error('too many copies') +} diff --git a/src/main/fs.ts b/src/main/fs.ts index ef98745..b3d8049 100644 --- a/src/main/fs.ts +++ b/src/main/fs.ts @@ -1,6 +1,6 @@ import electron from 'electron' import { join, basename, relative, isAbsolute, normalize, sep, dirname, extname } from 'path' -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, rmSync, renameSync, copyFileSync } from 'fs' +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, rmSync, renameSync } from 'fs' import { execFile, spawnSync } from 'child_process' import { IGNORE, MAX_ATTACHMENT_FILE_BYTES, MAX_ATTACHMENT_FILE_MB, MAX_FILE_BYTES } from './fs-constants' import { isRemoteRoot } from './remote/ssh-target' @@ -82,21 +82,7 @@ export function registerFsIpc(): void { ipcMain.handle('fs:move', (_e, root: string, srcRel: string, destDirRel: string) => { if (isRemoteRoot(root)) return remoteMove(root, srcRel, destDirRel) - if (!root || !isAbsolute(root)) return { error: 'absolute root required' } - const src = join(root, srcRel) - if (!safeUnder(root, src)) return { error: 'source escapes root' } - if (!existsSync(src)) return { error: 'source missing' } - const destDir = destDirRel ? join(root, destDirRel) : root - if (!safeUnder(root, destDir)) return { error: 'destination escapes root' } - const dest = join(destDir, basename(src)) - if (!safeUnder(root, dest)) return { error: 'destination escapes root' } - if (existsSync(dest)) return { error: `${basename(src)} already exists there` } - try { - renameSync(src, dest) - return { ok: true, rel: relative(root, dest) } - } catch (err) { - return { error: (err as Error).message } - } + return service.move(root, srcRel, destDirRel) }) ipcMain.handle('fs:delete', (_e, root: string, sub: string) => { @@ -129,24 +115,9 @@ export function registerFsIpc(): void { } }) - ipcMain.handle('fs:copyFile', (_e, root: string, sub: string) => { - if (isRemoteRoot(root)) return remoteCopyFile(root, sub) - if (!root || !isAbsolute(root)) return { error: 'absolute root required' } - const target = join(root, sub) - if (!safeUnder(root, target)) return { error: 'path escapes root' } - if (!existsSync(target)) return { error: 'path missing' } - const ext = extname(target) - const base = basename(target, ext) - const dir = dirname(target) - let dest = join(dir, `${base} copy${ext}`) - let n = 2 - while (existsSync(dest)) { dest = join(dir, `${base} copy ${n}${ext}`); n++ } - try { - copyFileSync(target, dest) - return { ok: true, rel: relative(root, dest) } - } catch (err) { - return { error: (err as Error).message } - } + ipcMain.handle('fs:copyFile', (_e, root: string, sub: string, destDirRel?: string) => { + if (isRemoteRoot(root)) return remoteCopyFile(root, sub, destDirRel) + return service.copyFile(root, sub, destDirRel) }) ipcMain.handle('fs:listFiles', (_e, root: string) => service.listFiles(root)) diff --git a/src/main/git-discard.test.ts b/src/main/git-discard.test.ts new file mode 100644 index 0000000..fd6aa06 --- /dev/null +++ b/src/main/git-discard.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { discardPath } from './git-discard' + +describe('discardPath', () => { + it('restores tracked changes including the index', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await discardPath('src/file.ts', run) + expect(run).toHaveBeenCalledWith(['restore', '--source=HEAD', '--staged', '--worktree', '--', 'src/file.ts']) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('removes an added file when there is no HEAD version', async () => { + const run = vi.fn() + .mockRejectedValueOnce(new Error('path is not in HEAD')) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + await discardPath('new.ts', run) + expect(run).toHaveBeenNthCalledWith(2, ['rm', '--force', '--cached', '--', 'new.ts']) + expect(run).toHaveBeenNthCalledWith(3, ['clean', '--force', '--', 'new.ts']) + }) +}) diff --git a/src/main/git-discard.ts b/src/main/git-discard.ts new file mode 100644 index 0000000..b7bb60b --- /dev/null +++ b/src/main/git-discard.ts @@ -0,0 +1,15 @@ +export type GitRunner = (args: string[]) => Promise<{ stdout: string; stderr: string }> + +/** Discard one working-tree change, including staged changes and untracked files. */ +export async function discardPath(path: string, run: GitRunner): Promise { + if (!path) throw new Error('path is required') + try { + await run(['restore', '--source=HEAD', '--staged', '--worktree', '--', path]) + return + } catch { + // An added file has no HEAD version. Remove it from the index first, then + // clean the working-tree copy below. + try { await run(['rm', '--force', '--cached', '--', path]) } catch { /* not indexed */ } + } + await run(['clean', '--force', '--', path]) +} diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 5432b44..bcf92eb 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -1,6 +1,7 @@ import { execFile } from 'child_process' import { parseLog, parseStatus } from './git-porcelain-parse' import { unstagePaths } from './git-unstage' +import { discardPath } from './git-discard' interface GitResult { stdout: string; stderr: string } @@ -31,6 +32,7 @@ export class GitService { async stage(cwd: string, paths: string[]) { try { await this.run(cwd, ['add', '--', ...paths.map(String)]); return { ok: true } } catch (error) { return { error: this.message(error) } } } async stageAll(cwd: string) { try { await this.run(cwd, ['add', '--all']); return { ok: true } } catch (error) { return { error: this.message(error) } } } async unstage(cwd: string, paths: string[]) { try { await unstagePaths(paths.map(String), args => this.run(cwd, args)); return { ok: true } } catch (error) { return { error: this.message(error) } } } + async discard(cwd: string, path: string) { try { await discardPath(String(path), args => this.run(cwd, args)); return { ok: true } } catch (error) { return { error: this.message(error) } } } async diff(cwd: string, path: string, staged: boolean) { try { diff --git a/src/main/git.ts b/src/main/git.ts index e6b6c49..0a225a9 100644 --- a/src/main/git.ts +++ b/src/main/git.ts @@ -333,6 +333,22 @@ export function registerGitIpc(): void { } }) + ipcMain.handle('git:discard', async (_e, cwd: string, path: string) => { + try { + const status = parseStatus((await runGit(cwd, ['status', '--porcelain=v1', '-b'])).stdout) + const relPath = String(path) + const known = [...status.staged, ...status.unstaged, ...status.untracked].some(file => file.path === relPath) + if (!known) return { error: 'file is no longer changed' } + if ([...status.untracked].some(file => file.path === relPath) && !status.staged.some(file => file.path === relPath) && !status.unstaged.some(file => file.path === relPath)) { + await runGit(cwd, ['clean', '--force', '--', relPath]) + } else { + try { await runGit(cwd, ['restore', '--source=HEAD', '--staged', '--worktree', '--', relPath]) } + catch { await runGit(cwd, ['rm', '--force', '--cached', '--', relPath]); await runGit(cwd, ['clean', '--force', '--', relPath]) } + } + return { ok: true } + } catch (err: unknown) { return { error: (err as Error).message } } + }) + ipcMain.handle('git:diff', async (_e, cwd: string, path: string, staged: boolean) => { try { // Force standard a/ b/ prefixes — the user's diff.mnemonicPrefix/noprefix diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index 5eceb96..ccc8826 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -328,6 +328,12 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['fs.mkdir', params => filesystemService.mkdir(registeredRoot(params), String(params.sub ?? ''))], ['fs.delete', params => filesystemService.delete(registeredRoot(params), String(params.sub ?? ''))], ['fs.rename', params => filesystemService.rename(registeredRoot(params), String(params.sub ?? ''), String(params.newName ?? ''))], + ['fs.copyFile', params => filesystemService.copyFile( + registeredRoot(params), + String(params.sub ?? ''), + Object.prototype.hasOwnProperty.call(params, 'destDirRel') ? String(params.destDirRel ?? '') : undefined, + )], + ['fs.move', params => filesystemService.move(registeredRoot(params), String(params.srcRel ?? ''), String(params.destDirRel ?? ''))], ['fs.listFiles', params => filesystemService.listFiles(registeredRoot(params))], ['attachments.begin', params => { if (attachmentUploads.size >= MAX_ACTIVE_ATTACHMENT_UPLOADS) throw new Error('too many active attachment uploads') @@ -407,6 +413,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['git.stage', params => gitService.stage(registeredRoot({ root: params.cwd }), Array.isArray(params.paths) ? params.paths.map(String) : [])], ['git.stageAll', params => gitService.stageAll(registeredRoot({ root: params.cwd }))], ['git.unstage', params => gitService.unstage(registeredRoot({ root: params.cwd }), Array.isArray(params.paths) ? params.paths.map(String) : [])], + ['git.discard', params => gitService.discard(registeredRoot({ root: params.cwd }), String(params.path ?? ''))], ['git.diff', params => gitService.diff(registeredRoot({ root: params.cwd }), String(params.path ?? ''), params.staged === true)], ['git.changesVsRef', params => gitService.changesVsRef(registeredRoot({ root: params.cwd }), String(params.ref ?? ''))], ['git.diffVsRef', params => gitService.diffVsRef(registeredRoot({ root: params.cwd }), String(params.ref ?? ''), String(params.path ?? ''))], diff --git a/src/main/remote/remote-fs.ts b/src/main/remote/remote-fs.ts index 1ce1077..6d01c1e 100644 --- a/src/main/remote/remote-fs.ts +++ b/src/main/remote/remote-fs.ts @@ -187,20 +187,44 @@ export async function remoteRename(root: string, sub: string, newName: string): }) } -export async function remoteCopyFile(root: string, sub: string): Promise<{ ok?: boolean; rel?: string; error?: string }> { +export async function remoteCopyFile(root: string, sub: string, destDirRel?: string): Promise<{ ok?: boolean; rel?: string; error?: string }> { const t = target(root); if ('error' in t) return t const r = resolveRemote(t, sub); if ('error' in r) return r + if (!sub) return { error: 'path missing' } + + const destDirResolved = destDirRel === undefined + ? { abs: posix.dirname(r.abs) } + : resolveRemote(t, destDirRel || '') + if ('error' in destDirResolved) return destDirResolved let sftp: SFTPWrapper try { sftp = await getSftp(t) } catch (e) { return { error: connErr(e) } } + const srcStat = await statRemote(sftp, r.abs) + if (!srcStat) return { error: 'path missing' } + const destDirStat = await statRemote(sftp, destDirResolved.abs) + if (!destDirStat) return { error: 'destination missing' } + if (!destDirStat.isDirectory()) return { error: 'destination is not a directory' } + + if (srcStat.isDirectory()) { + const destAbs = destDirResolved.abs + if (destAbs === r.abs || destAbs.startsWith(`${r.abs}/`)) { + return { error: 'cannot copy a folder into itself' } + } + } - const ext = posix.extname(r.abs) - const stem = posix.basename(r.abs, ext) - const dir = posix.dirname(r.abs) - let dest = posix.join(dir, `${stem} copy${ext}`) - let n = 2 - while (await statRemote(sftp, dest)) { dest = posix.join(dir, `${stem} copy ${n}${ext}`); n++ } + const original = posix.basename(r.abs) + const ext = posix.extname(original) + const stem = posix.basename(original, ext) + let name = original + let n = 1 + while (await statRemote(sftp, posix.join(destDirResolved.abs, name))) { + name = n === 1 ? `${stem} copy${ext}` : `${stem} copy ${n}${ext}` + n++ + if (n > 10_000) return { error: 'too many copies' } + } + const dest = posix.join(destDirResolved.abs, name) + const safe = resolveRemote(t, posix.relative(t.path, dest)); if ('error' in safe) return safe const res = await execRemote(t, `cp -R ${sh(r.abs)} ${sh(dest)}`).catch(e => ({ code: 1, stdout: '', stderr: connErr(e) })) return res.code === 0 ? { ok: true, rel: posix.relative(t.path, dest) } : { error: res.stderr.trim() || 'copy failed' } } diff --git a/src/preload/index.ts b/src/preload/index.ts index ef49f0c..0842662 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -282,7 +282,7 @@ contextBridge.exposeInMainWorld('electronAPI', { fsFormat: (root: string, sub: string, text: string) => ipcRenderer.invoke('fs:format', root, sub, text), fsDelete: (root: string, sub: string) => ipcRenderer.invoke('fs:delete', root, sub), fsRename: (root: string, sub: string, newName: string) => ipcRenderer.invoke('fs:rename', root, sub, newName), - fsCopyFile: (root: string, sub: string) => ipcRenderer.invoke('fs:copyFile', root, sub), + fsCopyFile: (root: string, sub: string, destDirRel?: string) => ipcRenderer.invoke('fs:copyFile', root, sub, destDirRel), fsMove: (root: string, srcRel: string, destDirRel: string) => ipcRenderer.invoke('fs:move', root, srcRel, destDirRel), // Writer binary formats are converted in main; renderer never receives raw @@ -406,6 +406,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('git:stageAll', cwd), gitUnstage: (cwd: string, paths: string[]) => ipcRenderer.invoke('git:unstage', cwd, paths), + gitDiscard: (cwd: string, path: string) => + ipcRenderer.invoke('git:discard', cwd, path), gitDiff: (cwd: string, path: string, staged: boolean) => ipcRenderer.invoke('git:diff', cwd, path, staged), gitChangesVsRef: (cwd: string, ref: string) => diff --git a/src/renderer/src/components/editor/FileTree.tsx b/src/renderer/src/components/editor/FileTree.tsx index 7f76bd6..f6f0862 100644 --- a/src/renderer/src/components/editor/FileTree.tsx +++ b/src/renderer/src/components/editor/FileTree.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useRef, useState, useCallback } from 'react' import { - FilePlusIcon, FolderPlusIcon, CopyIcon, ClipboardTextIcon, PencilIcon, TrashIcon + FilePlusIcon, FolderPlusIcon, ScissorsIcon, CopyIcon, ClipboardIcon, ClipboardTextIcon, PencilIcon, TrashIcon } from '@phosphor-icons/react' import { Icon } from '../ui/Icon' import { BeardedFileIcon } from './bearded-file-icons' import type { EditorOutlineSymbol } from './editor-outline' import type { FsNode } from '../../types' +import { canPasteInto, parentRel, pasteTargetDirRel, type TreeClipboard } from './file-tree-clipboard' interface SearchResult { rel: string @@ -57,7 +58,7 @@ interface CtxMenu { } function parentOf(rel: string): string { - return rel.includes('/') ? rel.substring(0, rel.lastIndexOf('/')) : '' + return parentRel(rel) } function isAncestorOf(ancestor: string, target: string): boolean { @@ -75,6 +76,7 @@ export function FileTree({ root, activeRel, onSelect, onSelectLine, onDiff, widt const [renameRel, setRenameRel] = useState(null) const [renameName, setRenameName] = useState('') const [ctx, setCtx] = useState(null) + const [clipboard, setClipboard] = useState(null) const [selectedRel, setSelectedRel] = useState(null) const [dragSrc, setDragSrc] = useState(null) const [dropTarget, setDropTarget] = useState(null) // rel of hovered dir, '' = root @@ -234,6 +236,7 @@ export function FileTree({ root, activeRel, onSelect, onSelectLine, onDiff, widt useEffect(() => { setCache({}) setRootErr(null) + setClipboard(null) expandedReportRef.current = null if (!root) return restoringRef.current = true @@ -379,6 +382,35 @@ export function FileTree({ root, activeRel, onSelect, onSelectLine, onDiff, widt navigator.clipboard.writeText(`${root}/${node.rel}`).catch(() => {}) } + function handleCopy(node: FsNode) { + setCtx(null) + setClipboard({ rel: node.rel, kind: node.kind, mode: 'copy' }) + } + + function handleCut(node: FsNode) { + setCtx(null) + setClipboard({ rel: node.rel, kind: node.kind, mode: 'cut' }) + } + + async function handlePaste(destDirRel: string) { + setCtx(null) + const clip = clipboard + const api = window.electronAPI + if (!api || !clip || !canPasteInto(clip, destDirRel)) return + if (clip.mode === 'cut') { + const result = await api.fsMove(root, clip.rel, destDirRel) + loadDir(parentOf(clip.rel)) + loadDir(destDirRel) + if (!result.rel) return + setClipboard(null) + if (clip.kind === 'file' && activeRel === clip.rel) onSelect(result.rel) + return + } + const result = await api.fsCopyFile(root, clip.rel, destDirRel) + loadDir(destDirRel) + if (clip.kind === 'file' && result.rel) onSelect(result.rel) + } + // ── Drag-and-drop ──────────────────────────────────────────────────────────── function onDragStart(e: React.DragEvent, node: FsNode) { @@ -437,6 +469,7 @@ export function FileTree({ root, activeRel, onSelect, onSelectLine, onDiff, widt const isActive = selectedRel === node.rel || (!isDir && activeRel === node.rel) const isRenaming = renameRel === node.rel const isDragging = dragSrc?.rel === node.rel + const isCut = clipboard?.mode === 'cut' && clipboard.rel === node.rel const isDropZone = isDir && dropTarget === node.rel return ( @@ -447,6 +480,7 @@ export function FileTree({ root, activeRel, onSelect, onSelectLine, onDiff, widt isDir ? 'dir' : 'file', isActive ? 'on' : '', isDragging ? 'ft-drag-ghost' : '', + isCut ? 'ft-cut' : '', isDropZone ? 'ft-drop-target' : '', ].filter(Boolean).join(' ')} style={{ paddingLeft: 6 + depth * 12 }} @@ -696,9 +730,26 @@ export function FileTree({ root, activeRel, onSelect, onSelectLine, onDiff, widt - {ctx.node &&
} +
+ + )} + {ctx.node && ( + <> + + )} + {ctx.node && !ctx.isDir && (
diff --git a/src/renderer/src/components/git/GitPageChanges.tsx b/src/renderer/src/components/git/GitPageChanges.tsx index 2fa8080..8968068 100644 --- a/src/renderer/src/components/git/GitPageChanges.tsx +++ b/src/renderer/src/components/git/GitPageChanges.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, type MouseEvent } from 'react' import { Icon } from '../ui/Icon' +import { ChatContextMenu, type ChatContextMenuItem } from '../chat/ChatContextMenu' import { PierreDiff } from '../diff/PierreDiff' import type { GitChange } from './git-state' import { getCrewCodeClient } from '../../runtime/crewcode-client' @@ -13,6 +14,7 @@ interface GitPageChangesProps { onUnstage?: (path: string) => void onStageAll?: (paths: string[]) => void onUnstageAll?: (paths: string[]) => void + onDiscard?: (path: string) => void } interface SelectedChange { @@ -21,15 +23,16 @@ interface SelectedChange { title: string } -function ChangeRow({ change, selected, onSelect, onStage, onUnstage }: { +function ChangeRow({ change, selected, onSelect, onStage, onUnstage, onContextMenu }: { change: GitChange selected: boolean onSelect: () => void onStage?: (path: string) => void onUnstage?: (path: string) => void + onContextMenu: (event: MouseEvent, change: GitChange) => void }) { return ( -
- {staged.map(change => select(change)} onStage={onStage} onUnstage={onUnstage} />)} + {staged.map(change => select(change)} onStage={onStage} onUnstage={onUnstage} onContextMenu={openContextMenu} />)} )} {unstaged.length > 0 && ( @@ -139,7 +165,7 @@ export function GitPageChanges({ repoPath, comparisonRef, changes, hasUnpushed, }}>stage all )} - {unstaged.map(change => select(change)} onStage={onStage} onUnstage={onUnstage} />)} + {unstaged.map(change => select(change)} onStage={onStage} onUnstage={onUnstage} onContextMenu={openContextMenu} />)} )} {changes.length === 0 && ( @@ -148,6 +174,7 @@ export function GitPageChanges({ repoPath, comparisonRef, changes, hasUnpushed, )} + {contextMenu && setContextMenu(null)} />}
{selected?.title ?? 'No file selected'} diff --git a/src/renderer/src/hooks/useGitSidebar.ts b/src/renderer/src/hooks/useGitSidebar.ts index 2f4f001..95a71a4 100644 --- a/src/renderer/src/hooks/useGitSidebar.ts +++ b/src/renderer/src/hooks/useGitSidebar.ts @@ -391,6 +391,7 @@ export function useGitSidebar(args: UseGitSidebarArgs): UseGitSidebarResult { // current state rather than replaying paths that may have moved meanwhile. onStageAll: (paths) => { if (paths.length) runAction('staging…', () => window.electronAPI!.gitStageAll(repoPath), 'staged') }, onUnstageAll: (paths) => { if (paths.length) runAction('unstaging…', () => window.electronAPI!.gitUnstage(repoPath, paths), 'unstaged') }, + onDiscardFile: (p) => runAction('discarding…', () => window.electronAPI!.gitDiscard(repoPath, p), 'discarded'), onCommit: ({ message, amend, push, sync }) => runAction( sync ? 'commit & sync…' : push ? 'commit & push…' : amend ? 'amending…' : 'committing…', async () => { diff --git a/src/renderer/src/runtime/web-rpc-client.ts b/src/renderer/src/runtime/web-rpc-client.ts index b0c05c5..1ea71d1 100644 --- a/src/renderer/src/runtime/web-rpc-client.ts +++ b/src/renderer/src/runtime/web-rpc-client.ts @@ -310,11 +310,14 @@ export function createWebCrewCodeClient(sessionOrTransport: string | WebClientTr fsMkdir: (root, sub) => rpc('fs.mkdir', { root, sub }), fsDelete: (root, sub) => rpc('fs.delete', { root, sub }), fsRename: (root, sub, newName) => rpc('fs.rename', { root, sub, newName }), + fsCopyFile: (root, sub, destDirRel) => rpc('fs.copyFile', destDirRel === undefined ? { root, sub } : { root, sub, destDirRel }), + fsMove: (root, srcRel, destDirRel) => rpc('fs.move', { root, srcRel, destDirRel }), fsListFiles: root => rpc('fs.listFiles', { root }), gitStatus: cwd => rpc('git.status', { cwd }), gitStage: (cwd, paths) => rpc('git.stage', { cwd, paths }), gitStageAll: cwd => rpc('git.stageAll', { cwd }), gitUnstage: (cwd, paths) => rpc('git.unstage', { cwd, paths }), + gitDiscard: (cwd, path) => rpc('git.discard', { cwd, path }), gitDiff: (cwd, path, staged) => rpc('git.diff', { cwd, path, staged }), gitChangesVsRef: (cwd, ref) => rpc('git.changesVsRef', { cwd, ref }), gitDiffVsRef: (cwd, ref, path) => rpc('git.diffVsRef', { cwd, ref, path }), diff --git a/src/renderer/src/styles/styles.css b/src/renderer/src/styles/styles.css index 80e8fda..34df41e 100644 --- a/src/renderer/src/styles/styles.css +++ b/src/renderer/src/styles/styles.css @@ -4995,6 +4995,7 @@ body.light .ncp-start svg { color: #285a48; } /* ─── File tree: drag and drop ──────────────────────────────────────────── */ .ft-row[draggable] { cursor: pointer; } .ft-drag-ghost { opacity: 0.4; } +.ft-cut { opacity: 0.45; } .ft-drop-target { background: rgba(40, 90, 72, 0.35) !important; outline: 1px solid #285a48; @@ -5044,6 +5045,15 @@ body.light .ncp-start svg { color: #285a48; } .ft-ctx-item.danger svg { opacity: 0.7; } .ft-ctx-item.danger:hover { background: rgba(192,60,60,.12); color: #e07070; } .ft-ctx-item.danger:hover svg { opacity: 1; } +.ft-ctx-item:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} +.ft-ctx-item:disabled:hover { + background: transparent; + color: var(--muted-foreground, #7a9a8a); +} .ft-ctx-sep { height: 1px; background: #1e3030; diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index b1258e9..00579e4 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -805,7 +805,7 @@ declare global { fsFormat: (root: string, sub: string, text: string) => Promise<{ ok?: boolean; text?: string; error?: string }> fsDelete: (root: string, sub: string) => Promise<{ ok?: boolean; error?: string }> fsRename: (root: string, sub: string, newName: string) => Promise<{ ok?: boolean; rel?: string; error?: string }> - fsCopyFile: (root: string, sub: string) => Promise<{ ok?: boolean; rel?: string; error?: string }> + fsCopyFile: (root: string, sub: string, destDirRel?: string) => Promise<{ ok?: boolean; rel?: string; error?: string }> fsMove: (root: string, srcRel: string, destDirRel: string) => Promise<{ ok?: boolean; rel?: string; error?: string }> writerDocumentsImport: (root: string, sourceRel: string) => Promise writerDocumentsExport: (root: string, sourceRel: string, markdown: string, format: WriterBinaryFormat) => Promise @@ -866,6 +866,7 @@ declare global { gitStage: (cwd: string, paths: string[]) => Promise<{ ok?: boolean; error?: string }> gitStageAll: (cwd: string) => Promise<{ ok?: boolean; error?: string }> gitUnstage: (cwd: string, paths: string[]) => Promise<{ ok?: boolean; error?: string }> + gitDiscard: (cwd: string, path: string) => Promise<{ ok?: boolean; error?: string }> gitDiff: (cwd: string, path: string, staged: boolean) => Promise<{ ok?: boolean; diff?: string; error?: string }> gitChangesVsRef: (cwd: string, ref: string) => Promise<{ ok?: boolean; files?: GitStatusFile[]; error?: string }> gitDiffVsRef: (cwd: string, ref: string, path: string) => Promise<{ ok?: boolean; diff?: string; error?: string }> From 613493cd5af746a579351b3cb5117be53fb77f93 Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Fri, 28 Aug 2026 19:02:54 -0400 Subject: [PATCH 02/10] ignore files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 016e628..cf735fd 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ tester/ test-workspace/ .commandcode/ examples/VPS_SETUP.md +examples/VPS_SETUP.md +.commandcode/settings.json +.commandcode/taste/taste.md From 2f33c30d6705f122c0a86d297fb38348150f848e Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Fri, 28 Aug 2026 21:08:21 -0400 Subject: [PATCH 03/10] feat: update package-lock.json and enhance tests with realpathSync for improved path handling --- package-lock.json | 16 +++++++++++++++- src/main/brain-authorization-policy.test.ts | 7 ++++--- src/main/hub-local-brain.test.ts | 9 +++++---- src/main/hub-machine-enrollment.test.ts | 4 ++-- src/main/hub-relay.test.ts | 8 +++++--- src/main/pty-service.yuheard.test.ts | 14 ++++++++++---- src/main/yuheard-wrapper.test.ts | 11 +++++++---- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87784dd..4b32a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,8 @@ }, "bin": { "crewcode": "bin/crewcode-server.mjs", - "crewcode-server": "bin/crewcode-server.mjs" + "crewcode-server": "bin/crewcode-server.mjs", + "yuheard": "bin/yuheard.mjs" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -1835,6 +1836,19 @@ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", diff --git a/src/main/brain-authorization-policy.test.ts b/src/main/brain-authorization-policy.test.ts index a668ef8..89a1b0d 100644 --- a/src/main/brain-authorization-policy.test.ts +++ b/src/main/brain-authorization-policy.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, statSync } from 'fs' +import { mkdirSync, mkdtempSync, realpathSync, statSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { describe, expect, it } from 'vitest' @@ -7,12 +7,13 @@ import { BrainAuthorizationPolicy, brainAuthorizationPolicyPath } from './brain- describe('Brain authorization policy', () => { it('persists canonical roots, scopes, and local audit across restart', () => { const dataDir = mkdtempSync(join(tmpdir(), 'brain-policy-')); const root = join(dataDir, 'workspace'); mkdirSync(root) + const canonicalRoot = realpathSync(root) let now = 100; const path = brainAuthorizationPolicyPath(dataDir) const policy = new BrainAuthorizationPolicy(path, [root], ['workspace:read'], () => now) now = 200 const updated = policy.update({ roots: [root], scopes: ['agent', 'workspace:read'], userId: 'owner' }) - expect(updated).toMatchObject({ scopes: ['agent', 'workspace:read'], roots: [root], audit: [{ userId: 'owner', at: 200 }] }) - expect(statSync(path).mode & 0o777).toBe(0o600) + expect(updated).toMatchObject({ scopes: ['agent', 'workspace:read'], roots: [canonicalRoot], audit: [{ userId: 'owner', at: 200 }] }) + if (process.platform !== 'win32') expect(statSync(path).mode & 0o777).toBe(0o600) expect(new BrainAuthorizationPolicy(path, [], [], () => 300).current()).toEqual(updated) }) it('rejects invalid policy', () => { diff --git a/src/main/hub-local-brain.test.ts b/src/main/hub-local-brain.test.ts index 573da45..864885e 100644 --- a/src/main/hub-local-brain.test.ts +++ b/src/main/hub-local-brain.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'events' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { dirname, join } from 'path' import { afterEach, describe, expect, it } from 'vitest' import type { ChildProcess } from 'child_process' import { @@ -51,13 +51,14 @@ describe('local Brain Hub origin matching', () => { describe('local Brain spawn plan', () => { it('spawns sibling brain.js from a compiled Hub entry', () => { + const scriptPath = '/opt/crewcode/out/main/hub.js' expect(localBrainSpawnPlan({ execPath: '/usr/bin/node', - scriptPath: '/opt/crewcode/out/main/hub.js', + scriptPath, brainArgv: ['--data-dir', '/brain'], })).toEqual({ execPath: '/usr/bin/node', - args: ['/opt/crewcode/out/main/brain.js', '--data-dir', '/brain'], + args: [join(dirname(scriptPath), 'brain.js'), '--data-dir', '/brain'], }) }) @@ -193,7 +194,7 @@ describe('local Brain supervisor', () => { warn: () => undefined, }) await ready - expect(spawned[0]?.[0]).toBe('/opt/crewcode/out/main/brain.js') + expect(spawned[0]?.[0]).toBe(join(dirname('/opt/crewcode/out/main/hub.js'), 'brain.js')) await supervisor.stop() expect(child.killed).toBe(true) }) diff --git a/src/main/hub-machine-enrollment.test.ts b/src/main/hub-machine-enrollment.test.ts index 7f88202..50688d5 100644 --- a/src/main/hub-machine-enrollment.test.ts +++ b/src/main/hub-machine-enrollment.test.ts @@ -1,7 +1,7 @@ import { generateKeyPairSync } from 'crypto' import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { join, resolve } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' import { HubDeviceEnrollmentIssuer, @@ -64,7 +64,7 @@ describe('Hub machine client security', () => { expect(() => parseBrainOptions([], 'enroll')).toThrow('requires --hub') expect(parseBrainOptions([], 'brain')).toMatchObject({ name: expect.any(String), allowedScopes: [], allowedWorkspaceRoots: [] }) expect(parseBrainOptions(['--workspace-root', '.', '--allow-scope', 'workspace:read', '--allow-scope', 'agent'], 'brain', '/tmp')).toMatchObject({ - allowedWorkspaceRoots: ['/tmp'], allowedScopes: ['workspace:read', 'agent'], + allowedWorkspaceRoots: [resolve('/tmp', '.')], allowedScopes: ['workspace:read', 'agent'], }) expect(() => parseBrainOptions(['--allow-scope', 'everything'], 'brain')).toThrow('invalid Brain scope') }) diff --git a/src/main/hub-relay.test.ts b/src/main/hub-relay.test.ts index 7c81010..31862d0 100644 --- a/src/main/hub-relay.test.ts +++ b/src/main/hub-relay.test.ts @@ -8,7 +8,7 @@ import { hkdfSync, verify, } from 'crypto' -import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'fs' import { createServer } from 'http' import { tmpdir } from 'os' import { join } from 'path' @@ -26,8 +26,10 @@ const cleanups: Array<() => void | Promise> = [] afterEach(async () => { while (cleanups.length) await cleanups.pop()?.() }) function directory(): string { - const value = mkdtempSync(join(tmpdir(), 'crewcode-hub-relay-')) - cleanups.push(() => rmSync(value, { recursive: true, force: true })) + const value = realpathSync(mkdtempSync(join(tmpdir(), 'crewcode-hub-relay-'))) + cleanups.push(() => { + try { rmSync(value, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } catch { /* Windows may keep SQLite/PTY handles briefly */ } + }) return value } diff --git a/src/main/pty-service.yuheard.test.ts b/src/main/pty-service.yuheard.test.ts index 635fafa..9e8bcab 100644 --- a/src/main/pty-service.yuheard.test.ts +++ b/src/main/pty-service.yuheard.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -46,11 +49,14 @@ function fakeProc() { } describe('PtyService YuHeard bundle boundary', () => { + let cwd: string + beforeEach(() => { vi.clearAllMocks() mocks.spawn.mockReturnValue(fakeProc()) mocks.installWrapper.mockReturnValue('/tmp/crewcode-yuheard-wrap/pane-1') mocks.fishArgv.mockReturnValue(['-C', 'yuheard-init']) + cwd = mkdtempSync(join(tmpdir(), 'pty-yuheard-')) }) it('uses injected server access and statically imported wrappers for a Fish shell', () => { @@ -68,7 +74,7 @@ describe('PtyService YuHeard bundle boundary', () => { const result = service.create({ paneId: 'pane-1', - cwd: '/tmp', + cwd, shell: 'fish', yuheard: true, autoWrap: true, @@ -76,7 +82,7 @@ describe('PtyService YuHeard bundle boundary', () => { }) expect(result.ok).toBe(true) - expect(notePaneSpawned).toHaveBeenCalledWith('pane-1', '/tmp') + expect(notePaneSpawned).toHaveBeenCalledWith('pane-1', cwd) expect(mocks.installWrapper).toHaveBeenCalledWith( 'pane-1', ['codex', 'claude'], @@ -121,7 +127,7 @@ describe('PtyService YuHeard bundle boundary', () => { const service = new PtyService(() => server) service.create({ paneId: 'pane-1', - cwd: '/tmp', + cwd, shell: 'fish', yuheard: true, autoWrap: true, @@ -158,7 +164,7 @@ describe('PtyService YuHeard bundle boundary', () => { const service = new PtyService(() => server) service.create({ paneId: 'pane-1', - cwd: '/tmp', + cwd, shell: 'fish', yuheard: true, autoWrap: true, diff --git a/src/main/yuheard-wrapper.test.ts b/src/main/yuheard-wrapper.test.ts index 939fa3d..063e83d 100644 --- a/src/main/yuheard-wrapper.test.ts +++ b/src/main/yuheard-wrapper.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, readFileSync, existsSync, statSync, rmSync, utimesSync, readdirSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { delimiter, join } from 'path' import { installYuHeardWrapper, installYuHeardHook, @@ -36,8 +36,10 @@ describe('installYuHeardWrapper', () => { expect(dir).toBe(join(baseDir, 'pn-1')) expect(existsSync(join(dir, 'claude'))).toBe(true) expect(existsSync(join(dir, 'codex'))).toBe(true) - const st = statSync(join(dir, 'claude')) - expect((st.mode & 0o100) !== 0).toBe(true) + if (process.platform !== 'win32') { + const st = statSync(join(dir, 'claude')) + expect((st.mode & 0o100) !== 0).toBe(true) + } }) it('embeds the socket path in the hook and does not call yuheard on PATH', () => { @@ -188,7 +190,8 @@ describe('pruneYuHeardWrappers', () => { describe('prependWrapperToPath', () => { it('prepends the wrapper dir to an existing PATH', () => { - expect(prependWrapperToPath('/wrappers/pn-1', '/usr/bin:/bin')).toBe('/wrappers/pn-1:/usr/bin:/bin') + const current = ['/usr/bin', '/bin'].join(delimiter) + expect(prependWrapperToPath('/wrappers/pn-1', current)).toBe(['/wrappers/pn-1', '/usr/bin', '/bin'].join(delimiter)) }) it('handles an empty PATH gracefully', () => { From 4b301d0d04f8187716acef76c13289fe08bd7a18 Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Fri, 28 Aug 2026 21:19:17 -0400 Subject: [PATCH 04/10] chore: match package.json description to README --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e1b5acd..41d509f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crewcode", "version": "0.2.1", - "description": "Desktop ACE for orchestrating AI coding agents In a elegant Chat UI across local project worktrees.", + "description": "CrewCode is the control center for multi-agent software development.", "author": { "name": "OnPoint Tools", "email": "Logix_Creations.Work@proton.me" From 42e91a0df05c2146d17f8351e84a7c381a4541d5 Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Fri, 28 Aug 2026 21:20:15 -0400 Subject: [PATCH 05/10] chore: release v0.2.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b32a34..f1cf34b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "crewcode", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crewcode", - "version": "0.2.1", + "version": "0.2.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 41d509f..f6f3dd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crewcode", - "version": "0.2.1", + "version": "0.2.2", "description": "CrewCode is the control center for multi-agent software development.", "author": { "name": "OnPoint Tools", From d10a62337182293d876c03f9ebe180c2dc6fc3bb Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Mon, 31 Aug 2026 23:01:33 -0400 Subject: [PATCH 06/10] feat: Desktop and web continuity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `isBrainAuthorizationDenial` function to differentiate between authorization denials and other errors. - Updated `WebConnectionScreen` to handle Brain authorization denials gracefully during workspace listing. - Introduced `hydrateContinuityState` to manage local storage synchronization with Brain's continuity state. - Created tests for Brain authorization denials and continuity state hydration. - Refactored `createWebCrewCodeClient` to support Brain-backed methods and maintain native desktop integrations. - Updated styles for improved UI consistency across desktop and web environments. - Added new types for Brain desktop connections and continuity state snapshots. - Desktop system-tray behavior is opt-in through **Settings → General → Keep running in background**. --- AGENTS.md | 55 +++- docs/chat-archiving.md | 24 +- docs/conversation-storage.md | 11 +- docs/crewcoder-provider.md | 37 ++- docs/current-state.md | 49 ++- docs/desktop-web-continuity.md | 148 +++++++++ docs/notifications.md | 23 ++ docs/prompt-skill-studio.md | 12 + docs/releasing.md | 15 +- docs/system-tray.md | 25 ++ docs/terminal-stream-performance.md | 71 +++++ docs/web-remote-access.md | 49 ++- package.json | 3 + src/main/agents/bridge-service.test.ts | 109 ++++++- src/main/agents/bridge-service.ts | 186 +++++++++-- src/main/agents/bridge-types.ts | 3 + src/main/agents/crewcoder-bridge.test.ts | 14 + src/main/agents/crewcoder-bridge.ts | 4 +- src/main/agents/custody-invariants.test.ts | 10 +- src/main/agents/custody-invariants.ts | 5 +- src/main/agents/custody-journal.test.ts | 15 +- src/main/agents/custody-journal.ts | 10 + src/main/agents/custody.test.ts | 2 +- src/main/agents/custody.ts | 2 + src/main/brain-desktop-rendezvous.ts | 58 ++++ src/main/brain-desktop-service.test.ts | 178 +++++++++++ src/main/brain-desktop-service.ts | 298 ++++++++++++++++++ src/main/continuity-state-service.test.ts | 43 +++ src/main/continuity-state-service.ts | 82 +++++ src/main/filesystem-service.test.ts | 14 +- src/main/filesystem-service.ts | 35 +- src/main/github-service.test.ts | 41 +++ src/main/github-service.ts | 65 +++- src/main/hub-brain-relay.ts | 121 +++++-- src/main/hub-machine-enrollment.ts | 76 ++++- src/main/hub-relay.test.ts | 2 + src/main/hub-server.test.ts | 7 +- src/main/hub-server.ts | 23 +- src/main/index.ts | 67 +++- .../remote-access-package-scripts.test.ts | 16 + src/main/remote-access-server.test.ts | 36 ++- src/main/remote-access-server.ts | 89 +++++- src/main/system-tray.test.ts | 97 ++++++ src/main/system-tray.ts | 120 +++++++ src/main/transcript-service.test.ts | 36 +++ src/main/transcript-service.ts | 59 +++- src/preload/index.ts | 17 + src/renderer/index.html | 11 +- src/renderer/src/App.tsx | 63 +++- .../src/components/archive/ArchivePage.tsx | 8 +- src/renderer/src/components/chat/ChatPane.tsx | 15 +- .../src/components/chat/SoloChatView.tsx | 8 +- .../src/components/composer/Composer.tsx | 8 +- .../composer/CrewCoderApprovalPicker.test.ts | 10 + .../composer/CrewCoderApprovalPicker.tsx | 38 +++ .../src/components/composer/ModelRow.test.ts | 9 +- .../src/components/composer/ModelRow.tsx | 63 +++- .../desktop-prompt-builder.test.ts | 17 + .../components/settings/SettingsScreen.tsx | 105 +++++- .../brain-continuity-settings.test.ts | 22 ++ .../settings/settings-section-focus.test.ts | 22 ++ .../settings/settings-section-focus.ts | 20 ++ .../src/components/terminal/TermColumn.tsx | 5 +- .../src/components/terminal/XTermPane.tsx | 34 +- .../terminal/terminal-output-buffer.test.ts | 81 +++++ .../terminal/terminal-output-buffer.ts | 136 ++++++++ src/renderer/src/components/ui/AppMenu.tsx | 5 +- src/renderer/src/components/ui/WindowTabs.tsx | 2 +- .../src/hooks/archive-retention.test.ts | 8 +- src/renderer/src/hooks/archive-retention.ts | 16 + .../src/hooks/chat-session-send.test.ts | 3 +- src/renderer/src/hooks/chat-session-send.ts | 7 +- .../src/hooks/updater-notices.test.ts | 35 ++ src/renderer/src/hooks/updater-notices.ts | 33 ++ .../useAgentBridge.thinking-stream.test.ts | 22 ++ src/renderer/src/hooks/useAgentBridge.ts | 35 +- src/renderer/src/hooks/useBridgeRegistry.ts | 7 +- .../src/hooks/useChatSessions.test.ts | 58 ++++ src/renderer/src/hooks/useChatSessions.ts | 65 +++- .../src/hooks/useComposerSend.test.ts | 15 +- src/renderer/src/hooks/useComposerSend.ts | 34 +- .../src/hooks/useCrewcodePromptFiles.test.ts | 58 ++++ .../src/hooks/useCrewcodePromptFiles.ts | 54 +++- src/renderer/src/hooks/useSettings.tsx | 10 + .../src/hooks/useUpdaterNotices.test.ts | 94 ++++++ src/renderer/src/hooks/useUpdaterNotices.ts | 43 +++ src/renderer/src/hooks/useWorkspaces.ts | 14 +- src/renderer/src/main.tsx | 63 +++- .../src/runtime/WebConnectionScreen.tsx | 21 +- .../brain-authorization-runtime.test.ts | 30 ++ .../runtime/brain-authorization-runtime.ts | 9 + .../src/runtime/continuity-state.test.ts | 46 +++ src/renderer/src/runtime/continuity-state.ts | 65 ++++ src/renderer/src/runtime/crewcode-client.ts | 2 +- src/renderer/src/runtime/hub-relay-client.ts | 20 +- .../runtime/startup-loading-screen.test.ts | 20 ++ .../src/runtime/web-rpc-client.test.ts | 64 +++- src/renderer/src/runtime/web-rpc-client.ts | 79 ++++- src/renderer/src/styles/prompt-builder.css | 14 + src/renderer/src/styles/settings.css | 8 + src/renderer/src/styles/styles.css | 2 +- src/renderer/src/types/index.ts | 18 ++ src/shared/brain-desktop-types.ts | 27 ++ src/shared/continuity-state-types.ts | 6 + src/shared/crewcoder-types.test.ts | 18 +- src/shared/crewcoder-types.ts | 18 ++ src/shared/remote-access-types.ts | 2 +- 107 files changed, 4009 insertions(+), 278 deletions(-) create mode 100644 docs/desktop-web-continuity.md create mode 100644 docs/system-tray.md create mode 100644 docs/terminal-stream-performance.md create mode 100644 src/main/brain-desktop-rendezvous.ts create mode 100644 src/main/brain-desktop-service.test.ts create mode 100644 src/main/brain-desktop-service.ts create mode 100644 src/main/continuity-state-service.test.ts create mode 100644 src/main/continuity-state-service.ts create mode 100644 src/main/github-service.test.ts create mode 100644 src/main/remote-access-package-scripts.test.ts create mode 100644 src/main/system-tray.test.ts create mode 100644 src/main/system-tray.ts create mode 100644 src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts create mode 100644 src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx create mode 100644 src/renderer/src/components/promptBuilder/desktop-prompt-builder.test.ts create mode 100644 src/renderer/src/components/settings/brain-continuity-settings.test.ts create mode 100644 src/renderer/src/components/settings/settings-section-focus.test.ts create mode 100644 src/renderer/src/components/settings/settings-section-focus.ts create mode 100644 src/renderer/src/components/terminal/terminal-output-buffer.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-output-buffer.ts create mode 100644 src/renderer/src/hooks/updater-notices.test.ts create mode 100644 src/renderer/src/hooks/updater-notices.ts create mode 100644 src/renderer/src/hooks/useCrewcodePromptFiles.test.ts create mode 100644 src/renderer/src/hooks/useUpdaterNotices.test.ts create mode 100644 src/renderer/src/hooks/useUpdaterNotices.ts create mode 100644 src/renderer/src/runtime/brain-authorization-runtime.test.ts create mode 100644 src/renderer/src/runtime/continuity-state.test.ts create mode 100644 src/renderer/src/runtime/continuity-state.ts create mode 100644 src/renderer/src/runtime/startup-loading-screen.test.ts create mode 100644 src/shared/brain-desktop-types.ts create mode 100644 src/shared/continuity-state-types.ts diff --git a/AGENTS.md b/AGENTS.md index 609f82f..93eeba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,27 @@ Update corresponding Docs in [CrewCode Docs](/docs/), and [AGENTS.md](/AGENTS.md Git Workspace changed-file rows support stage/unstage controls and a context menu for stage, stage-all, unstage, and explicitly confirmed discard actions. +Desktop system-tray behavior is opt-in. When enabled, closing the window hides +it while app-owned work continues; the tray must expose explicit Open and Quit +actions, and Quit must pass through normal cleanup. Disabling the preference +removes the tray immediately. Retain the macOS Dock icon and do not expose tray +behavior to web, Hub, or headless runtimes. See `docs/system-tray.md`. + +Chat sessions persist independent `createdAt`, `lastUsedAt`, and `archivedAt` +timestamps. Advance `lastUsedAt` only when work is sent through the chat; the +Archive page displays it as `MM/DD/YYYY`, while retention continues to use only +`archivedAt`. + +Inactive standalone terminal tabs stay mounted to preserve their PTYs, but must +pass `active={false}` through `TermColumn` to `XTermPane`. Buffer their output +without `term.write()`, then refit and replay it with bounded frame work and +xterm callback backpressure when activated. Keep bridge activity phase changes +inside the existing 50 ms text/thinking stream flush. Idle App-owned pollers +must preserve state identity when data is unchanged, remain single-flight, and +use asynchronous filesystem/child-process APIs; never put sync I/O or +`spawnSync` in an automatic refresh path. See +`docs/terminal-stream-performance.md`. + ## What is CrewCode? CrewCode is a desktop ACE (Agent Coding Environment) GUI built with Electron + React + TypeScript. It lets developers run a *crew* of AI coding agents (Claude Code, Codex, OpenCode, etc.) in parallel across local git worktrees, each in its own workspace with a chat thread, embedded terminal panes, and a code/markdown editor — all in one frameless native-feeling window. @@ -148,6 +169,32 @@ Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for a The shared React renderer supports desktop and direct browser clients. New renderer code must obtain privileged operations through the typed CrewCode client boundary in `src/renderer/src/runtime/crewcode-client.ts`; do not introduce transport-specific HTTP/WebSocket calls in components. Electron installs `window.electronAPI`; the web adapter implements the same contract over authenticated, versioned HTTP/WebSocket RPC. Protocol envelopes live in `src/shared/remote-access-types.ts`; see `docs/web-remote-access.md`. +Optional desktop/web continuity attaches Electron to an enrolled, detached background +Brain through an owner-only loopback rendezvous. Once attached, the Brain store is +authoritative for routed workspaces, transcripts, replay/resume state, terminals, +agents, and the allowlisted workspace/chat catalogue; Electron retains native-only +integrations through the composite client. Seed only missing Brain state, preserve +provider-specific resume keys, and alias legacy `thread:` replay to `web:` without +overwriting existing Brain data. Normal desktop close must not stop the Brain; only an +explicit Stop Brain/Quit-and-stop action withdraws remote availability. Serialize +prompts FIFO within one conversation while allowing different conversations to run in +parallel, and merge divergent full transcript saves instead of letting stale clients +clobber observed turns. Keep Hub scopes/registered-root checks intact and never treat +this as file synchronization. Keep the pre-React startup surface present while Electron +probes, attaches to, and hydrates from an enabled Brain; startup status is observational +and must never imply attachment success before it is observed. Desktop & Web Settings +must probe and show the Hub's observed canonical browser/passkey origin without exposing +its machine credential; never substitute the enrollment address for an observed browser +origin or imply that enabling Brain starts or proves reachability of the separate Hub +service. See +`docs/desktop-web-continuity.md`. + +Source-checkout remote-access scripts are `npm run enroll -- --hub `, `npm run +brain`, and `npm run hub:mobile`. Keep mobile Hub fail-closed around an existing +Tailscale Serve configuration: replacement requires explicit `--tailscale-replace`. +Do not run the foreground `npm run brain` against the default Brain data directory +while Electron Background Brain owns it. + Remote-access credentials are authority boundaries. Pairing tokens must remain short-lived, memory-only, and single-use. Persist only device-session digests in owner-only atomic stores; enforce expiry and revocation. Browser HTTP/WebSocket origins must match exactly or be explicitly configured—never reflect arbitrary `Origin`/forwarded headers. Keep authentication limiters bounded, and do not hardcode CJ's `crewcode.logixhub.icu` deployment as a default Hub URL. Browser delegation keeps its agent-facing endpoint Brain-loopback and bearer-scoped; @@ -158,7 +205,7 @@ the trusted renderer plus manifest permission gate. Remote GitHub UI may drive t Brain's `gh` device login and registered-workspace publishing, but must never expose the Brain's GitHub credential or allow remote logout. -The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. `crewcode hub --local-brain` may spawn a sibling `crewcode brain` on the Hub host after owner passkey setup; keep Hub SQLite and Brain credentials in separate data dirs, do not default-grant scopes, and still enroll extra machines with `crewcode enroll` then `crewcode brain`. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Machine enrollment tokens must also stay short-lived, memory-only, single-use, and rate-limited; persist only machine bearer digests at the Hub and keep the brain credential file owner-only. Presence and relay connections are outbound-only and revocation must fail closed. Hub connection tickets remain short-lived, memory-only, one-shot, browser-session/user/machine bound, and exact-origin protected. Relay application frames must stay end-to-end encrypted and ordered; the Hub may route metadata but must not receive RPC/source/terminal/agent plaintext. Do not let Hub identity, machine presence, or requested ticket scope imply Brain execution authority: `crewcode brain` defaults to no RPC grants, and every decrypted method must pass both explicit Brain-local scope and registered-workspace validation. Relay loss means pending outcomes are interrupted, never successful. +The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. `crewcode hub --local-brain` may spawn a sibling `crewcode brain` on the Hub host after owner passkey setup; keep Hub SQLite and Brain credentials in separate data dirs, do not default-grant scopes, and still enroll extra machines with `crewcode enroll` then `crewcode brain`. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Machine enrollment tokens must also stay short-lived, memory-only, single-use, and rate-limited; persist only machine bearer digests at the Hub and keep the brain credential file owner-only. Presence and relay connections are outbound-only and revocation must fail closed. Hub connection tickets remain short-lived, memory-only, one-shot, browser-session/user/machine bound, and exact-origin protected. Relay application frames must stay end-to-end encrypted and ordered; the Hub may route metadata but must not receive RPC/source/terminal/agent plaintext. Do not let Hub identity, machine presence, or requested ticket scope imply Brain execution authority: `crewcode brain` defaults to no RPC grants, and every decrypted method must pass both explicit Brain-local scope and registered-workspace validation. Relay loss means pending outcomes are interrupted, never successful. Preserve the first observed encrypted-relay close reason through browser startup and record bounded close metadata (peer, WebSocket code, and reason) in the Hub audit store; never replace it with a later generic disconnected error or log relay payloads. Brain-to-browser encrypted frames must use bounded callback-backed ordering and advance their sequence/nonce only after the preceding WebSocket send is accepted; serialization, transport, or queue failure closes the affected tunnel rather than creating a sequence hole. Remote cross-thread conversation handoff stays Brain-local. Namespace browser replay shards under `web:`; never copy the replay store into browser persistence. Require an authenticated owner-held destination bridge and Brain-local `agent` scope, refuse handoff while the destination is running, perform bounded disposable summarization on the Brain, clear the destination native resume id, and replay the combined destination history exactly once on its next native-provider prompt. Missing history, lost ownership, or summary failure is an explicit failure, never inferred success. @@ -174,7 +221,7 @@ The design system lives in `.design/crewcode-design-system/`. The canonical CSS Renderer components may use Tailwind v4 utilities through the utilities-only integration in `src/renderer/src/styles/tailwind.css`. Preflight must stay disabled so incremental conversions do not reset unrelated app surfaces. Use the `cc-*` semantic Tailwind colors, which map to the canonical live CSS tokens; see `docs/tailwind-renderer.md`. -The Prompt/Skills Studio phone list is an edge-to-edge surface, not a centered percentage-width card. Keep the `.pb` → `.pb-left` → `.pb-inner` container chain at `width: 100%`, `max-width: 100%`, and `min-width: 0`. Do not render the category-chip scroller on phones; retain only its compact management/favorite/layout toolbar. Phone cards must be non-shrinking children of the scrollable flex list, grow to fit their wrapped title and description, and contain overflow without line clamps. The phone detail editor must not offer or render Split mode: resolve a stored desktop Split state to Source, retain explicit Source/Preview choices, and let `.pd-source` fill the remaining body height. Actionable controls remain at least 36px and text inputs remain at the iOS-safe 16px. +The Prompt/Skills Studio desktop rail keeps its header, filters, and footer fixed while `.pb-list` scrolls independently; preserve the `.pb-left` → `.pb-inner` → `.pb-list` flex-height chain and `min-height: 0` above 768px. The phone list is an edge-to-edge surface, not a centered percentage-width card. Keep the `.pb` → `.pb-left` → `.pb-inner` container chain at `width: 100%`, `max-width: 100%`, and `min-width: 0`. Do not render the category-chip scroller on phones; retain only its compact management/favorite/layout toolbar. Phone cards must be non-shrinking children of the scrollable flex list, grow to fit their wrapped title and description, and contain overflow without line clamps. The phone detail editor must not offer or render Split mode: resolve a stored desktop Split state to Source, retain explicit Source/Preview choices, and let `.pd-source` fill the remaining body height. Actionable controls remain at least 36px and text inputs remain at the iOS-safe 16px. The composer PromptPicker has separate Prompts and Skills tabs backed by the shared prompt library. Prompt selection inserts into the visible composer (using variable fill when required); Skill selection toggles only the resolved session's `enabledSkillIds`, remains open for multi-select, and never inserts the skill body or mutates a global enable flag. Keep enabled state visible and phone tabs/rows at least 44px/48px respectively. @@ -208,7 +255,7 @@ Three tsconfigs compose via project references: ## Current state -Read this file only when working on any of the features below and need the Current state of them `CrewCoder provider`, `ACP Grok Build`, `Sidebar Folder Creation`, `Crew Supervisor`, `Delegated Threads`,`Chat Archiving`, `Hide work Logs`, `Realtime Voice Orb`, `Notifcation Sound`, `Agent Messages`, `Agent Task Activity`, `Cusromization Panel`, `Queued Messages`, `Composer Execution Modes & reasoning`, `Claude SDK Global skills isolation`, `Provider Switch Handoff & Compact`, `Chat`, `Drawer session split`, `Markdown Editor`, `Code Editor`, `Workbench Mode`, `Git Workspace/Sidebar`, `Mobile-responsive Pages`, [Current State](docs/current-state.md) +Read this file only when working on any of the features below and need the Current state of them `CrewCoder provider`, `ACP Grok Build`, `Sidebar Folder Creation`, `Crew Supervisor`, `Delegated Threads`,`Chat Archiving`, `Hide work Logs`, `Realtime Voice Orb`, `Notifcation Sound`, `App updates`, `Agent Messages`, `Agent Task Activity`, `Cusromization Panel`, `Queued Messages`, `Composer Execution Modes & reasoning`, `Claude SDK Global skills isolation`, `Provider Switch Handoff & Compact`, `Chat`, `Drawer session split`, `Markdown Editor`, `Code Editor`, `Workbench Mode`, `Git Workspace/Sidebar`, `Mobile-responsive Pages`, [Current State](docs/current-state.md) Agent activity must not depend on prompt instructions or provider tool compliance. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a dedicated CrewCode-owned `activity` transcript record for that turn; raw PTY agents are excluded because their terminal outcome is not observable. Advance it only from observed bridge events: `turn_start` begins work, tool categories may update its deterministic phase, and normal `turn_end` completes it. Prompt rejection, abort, stop, bridge error/closure, custody halt, or lost runtime becomes cancelled/interrupted, never success. Terminal activity is immutable, and a persisted running record from another app runtime projects as interrupted. Provider-native todo/plan/task snapshots may replace the generic row only while the CrewCode lifecycle is active; the CrewCode terminal outcome wins over stale native pending/in-progress state. @@ -216,7 +263,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. A profile change is a launch-flag change, so drop only the idle CrewCoder bridge and native-resume it on the next prompt. 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 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`. Provider context handoff is initiated from the Solo Chat header or `/handoff`. Its Used chats tab mirrors the current workspace's live Sessions catalogue across chat tabs; starting either a new or used destination closes the card immediately and moves progress/failure feedback to the destination meter. Preserve each selected destination's owner tab/worktree, existing provider/model/effort locking, and disposable destination-provider summary flow documented in `docs/provider-context-handoff.md`. diff --git a/docs/chat-archiving.md b/docs/chat-archiving.md index 55618bc..e41bfc3 100644 --- a/docs/chat-archiving.md +++ b/docs/chat-archiving.md @@ -12,6 +12,26 @@ full on-disk transcript all survive. surface. The **Archive page** (App menu → Archive) is the one place to see them: a single cross-workspace list with search, a workspace filter, per-row Restore/Delete, and the retention control. +- Every archive row shows the chat's **last-used calendar date** as + `MM/DD/YYYY`. Archiving does not change that value: a chat last used on + `08/28/2026` and archived on `08/30/2026` still displays `08/28/2026`. + +## Chat timestamps + +Each newly created `Session` records two independent wall-clock values: + +- `createdAt` is stamped once when the chat is created. +- `lastUsedAt` starts at creation and advances only when work is sent through + that chat (including normal prompts, follow-ups, voice sends, delegated + prompts, browser-grab sends, handoffs, and `/compact`). Opening, renaming, + pinning, restoring, or archiving a chat does not advance it. + +The Archive page displays and sorts by `lastUsedAt`; `archivedAt` remains +separate and continues to drive retention. Sessions saved before these fields +existed are backfilled once from transcript modification times, which are the +best available evidence of actual activity because archive/restore does not +rewrite transcripts. If no transcript metadata exists, CrewCode uses the +archive timestamp or first-launch-after-upgrade time as a non-zero fallback. ## Retention @@ -71,8 +91,8 @@ of the session in `crewcode:sessionsByTab`. | File | Role | | --- | --- | -| `src/renderer/src/types/index.ts` | `Session.archived`, `Session.archivedAt`, `archive` tab kind | -| `src/renderer/src/hooks/useChatSessions.ts` | `setArchived`, `backfillArchivedAt`, live-only accessors, id allocation | +| `src/renderer/src/types/index.ts` | `Session.createdAt`, `Session.lastUsedAt`, `Session.archived`, `Session.archivedAt`, `archive` tab kind | +| `src/renderer/src/hooks/useChatSessions.ts` | session timestamp creation/backfill/touch, `setArchived`, `backfillArchivedAt`, live-only accessors, id allocation | | `src/renderer/src/hooks/archive-retention.ts` | pure expiry/age rules | | `src/renderer/src/hooks/useSettings.tsx` | `archiveRetentionDays` + its fail-safe normalization | | `src/renderer/src/App.tsx` | `archiveSession` / `restoreSession` / `renameSession`, live vs archived grouping, page wiring | diff --git a/docs/conversation-storage.md b/docs/conversation-storage.md index cde33f9..23029b7 100644 --- a/docs/conversation-storage.md +++ b/docs/conversation-storage.md @@ -34,7 +34,9 @@ If a sharded file is missing or unreadable, CrewCode can lazily recover that ses ### Browser/Brain conversation scopes -Remote browser replay history remains authoritative on the Brain, not in browser `localStorage`. The shared renderer supplies an opaque chat session id; the remote boundary namespaces it as `web:` before `AgentBridgeService` reads or writes the same per-session conversation shards described above. This keeps desktop `thread:` keys and browser keys from aliasing each other. +Remote browser and Brain-attached desktop replay history remains authoritative on the Brain, not in renderer `localStorage`. The shared renderer supplies an opaque chat session id; the remote boundary namespaces it as `web:` before `AgentBridgeService` reads or writes the same per-session conversation shards described above. First-time desktop attachment copies missing state and creates non-destructive `web:` aliases for existing `thread:` shards. Provider-native resume IDs continue to use the desktop-compatible `:` key, so switching clients does not lose resume state and switching providers cannot consume another provider's native id. + +The Brain also serializes prompt entry per conversation. Desktop and web may submit concurrently, but one conversation receives one provider turn at a time in FIFO order; different conversations remain concurrent. Stable bridge starts are coalesced so simultaneous first attachment does not create competing provider processes. Cross-thread browser handoff is a bounded Brain-side operation. The browser names a source chat and an already-owned destination bridge, but never downloads the source replay shard. The Brain summarizes the source with a disposable destination-provider bridge, appends only the resulting handoff packet to the destination shard, clears the destination's native resume id, and replays the combined destination history once on its next native-provider prompt. Stateless HTTP providers consume the updated shard directly. Missing source history, summary failure, a running destination, or lost destination ownership is an explicit failure and is never inferred as success. @@ -96,6 +98,13 @@ The rich UI thread (the full renderer `Message[]` — user/agent/thinking/toolca Messages are stored opaquely — the main process never inspects their shape, so the renderer `Message` type stays renderer-only. IPC surface: `transcripts:loadAll`, `transcripts:save`, `transcripts:remove`, and a **synchronous** `transcripts:saveSyncBatch` used only on window teardown (an async `invoke` can be dropped before the renderer dies, so the last turn is written synchronously). +In a Brain-attached runtime, `src/main/transcript-service.ts` owns the equivalent +Brain-side shards. Since desktop and browser can save full arrays based on different +snapshots, it merges by stable message identity (ignoring client-local display time +where no durable id exists) before writing. New divergent rows are appended in Brain +receipt order, known activity/tool/turn rows are replaced, and explicit +`transcripts.remove` remains the only whole-thread deletion path. + ### L1 — `crewcode:messagesByTab` localStorage (bounded fast-paint cache) `src/renderer/src/stores/chat-messages-store.ts` keeps a synchronous localStorage copy so the transcript paints instantly on launch. localStorage has a hard ~5MB per-origin quota; the cache therefore caps each scope's tail (`MAX_PERSISTED_MESSAGES_PER_SCOPE`) and, on `QuotaExceededError`, evicts the least-recently-touched scopes so the newest conversation always wins the remaining space. diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index fbe15fc..c8f9f2e 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -36,13 +36,30 @@ 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: + +- **Review** (`review`) lets safe calls proceed and asks for mutations and dangerous calls. +- **Always** (`always`) asks for every non-safe call. +- **Never** (`never`) shows no prompts while continuing to block dangerous calls. +- **Full access** (`full-access`) accepts calls without prompts. +- **Sandboxed** (`sandboxed`) shows no prompts and runs non-dangerous calls through the sandbox policy where supported. + +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 +authority escalation: CrewCoder stops emitting approval requests and permits +dangerous commands, so CrewCode's permission overlay and dangerous-command +tripwire cannot interpose on those provider-native calls. + A concrete CrewCoder profile also owns the agent's behavioral mode, so CrewCode locks its separate execution policy to **Build** and disables the -Ask/Plan/Build/Full control. Build remains active underneath as the approval -gate: writes still require CrewCode's permission overlay instead of becoming -implicitly Full Access. The phone model menu disables its Mode row for the same -session. Returning to **Configured default** re-enables the execution-mode -control; the session remains on Build until the user chooses another policy. +Ask/Plan/Build/Full control. Under the default Review policy, Build remains the +CrewCode permission gate. Explicit CrewCoder Full access bypasses that native +request path and must not be described as Build-protected. The phone model menu +disables its Mode row for the same session. Returning to **Configured default** +re-enables the execution-mode control; the session remains on Build until the +user chooses another policy. The `crewcoder` profile adds a runtime inspect → clarify → plan → approve sequence inside CrewCoder. CrewCode does not enforce that gate; it projects @@ -50,7 +67,8 @@ sequence inside CrewCoder. CrewCode does not enforce that gate; it projects and sends `/approve-plan` as a user prompt when the user clicks **approve plan** or picks the CrewCoder slash command. That prompt is not `/approve` and does not settle a `session/request_permission` card. After plan approval, CrewCode -Build permission prompts still apply to mutating tools. Revising a proposed +handles any permission requests emitted by the selected native approval policy; +Never, Full access, and Sandboxed may deliberately emit none. Revising a proposed plan is a normal composer message; CrewCoder treats that as a new `awaiting_plan` cycle rather than approval. @@ -182,8 +200,11 @@ CrewCoder's separate agent-profile `--mode` option (`general`, `crewcoder`, `plugin`, `extension`). The selected `Session.crewcoderMode` is the only value allowed onto that launch flag. It is process-scoped, whereas CrewCode execution mode remains the permission policy described above. A concrete CrewCoder -profile fixes that policy to Build; it must never inherit a hidden prior Ask, -Plan, or Full Access value. +profile fixes that CrewCode policy to Build; it must never inherit a hidden +prior Ask, Plan, or Full Access value. `Session.crewcoderApprovalMode` is a +separate native authority value. CrewCode passes only `review`, `always`, +`never`, `full-access`, or `sandboxed` to `--approval`, defaults +missing/invalid values to `review`, and records the value in execution custody. ## Filesystem and SSH behavior diff --git a/docs/current-state.md b/docs/current-state.md index 80f61e9..62b5dd8 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -1,16 +1,43 @@ # Current state +Desktop system-tray behavior is opt-in through **Settings → General → Keep running in background**. Enabling it creates an OS tray with explicit **Open CrewCode** and **Quit CrewCode** actions; closing the window hides it without stopping terminals or agents, while tray Quit follows the normal cleanup path. Windows and Linux also restore on tray click (Linux StatusNotifierItem Activate is typically a single click). Disabling the preference removes the tray immediately. macOS retains its Dock icon, and web/Hub/headless runtimes never emulate the tray. See `docs/system-tray.md`. + Real agent integration is wired through normalized bridges (pi, OpenCode, Claude, Codex, Hermes, CrewCoder, Ollama, and OpenRouter) with PTY panes remaining available for terminals. Workspaces, worktrees, git operations, terminals, settings, and crew sessions are all real and persisted (workspaces + tabs to disk, messages to localStorage). +## Desktop and web continuity + +An enrolled desktop can enable **Settings → Desktop & Web → Background Brain**. The +optional detached Brain becomes authoritative for Brain-authorized workspaces, rich +transcripts, replay/native-resume state, terminals, agents, and the bounded workspace/ +chat catalogue used by both Electron and Hub web. First enable seeds missing Electron +state without overwriting existing Brain data and aliases legacy `thread:` replay into +the shared `web:` scope. Normal desktop close leaves the Brain and remote availability +running; **Stop Brain** and **Quit and stop Brain** explicitly terminate it. + +Desktop and web may submit prompts concurrently. Stable bridge starts are coalesced, +and the Brain serializes one conversation FIFO while allowing different conversations +to execute in parallel. Transcript saves merge divergent client snapshots so one +client cannot erase a turn already observed from the other. Catalogue hydration occurs +before app mount and later local edits push bounded per-key patches; live navigation +pull between already-open clients remains a first-release limitation. Files stay on +the enrolled machine, Hub relay traffic remains end-to-end encrypted, and Hub identity +still cannot widen Brain-local roots/scopes. A pre-React startup surface reports Brain +probe, attachment, and hydration phases during the enable-triggered reload instead of +leaving the desktop window blank. Desktop & Web Settings probes the enrolled Hub, shows +its observed canonical browser/passkey origin with an **Open Hub** action, and states +that enabling Brain does not start the separate Hub service. The enrollment address is +never treated as proof of browser origin or reachability. See +`docs/desktop-web-continuity.md`. + Settings → General stores a workspace-scoped default branch. The selector detects local branches from the active repository. A new solo-chat session captures that setting once, reuses or creates the branch worktree, selects it for that chat surface, then clears the one-shot request so existing chats and later manual branch switches are never moved retroactively. Git Workspace and Git Sidebar also use the live setting as their comparison base without checking it out: committed branch differences remain reviewable alongside local status, while only true working-tree rows expose stage/unstage actions. Delegated threads retain their separate base/worktree contract. ## CrewCoder -When the active installed provider is CrewCoder, the desktop model-row reveal offers session-scoped Configured default/general/crewcoder/plugin/extension profiles. Configured default omits the launch flag; a concrete choice adds CrewCoder's distinct `--mode`, restarts only that bridge before native resume, locks CrewCode's underlying permission policy to Build, and disables Ask/Plan/Build/Full on desktop and phone until Configured default is restored. Never pass CrewCode's execution-mode value as the CrewCoder profile, and never let a concrete profile inherit a hidden prior Ask, Plan, or Full Access policy. The `crewcoder` profile's inspect/clarify/propose/approve-plan gate is CrewCoder-owned: CrewCode renders `crewcoder_clarify` and `crewcoder_propose_plan` on the activity overlay and sends `/approve-plan` as a prompt from the overlay button or slash command. Do not reuse tool-permission Allow/Deny for that card, and do not treat a clarification answer as plan approval. +When the active installed provider is CrewCoder, the desktop model-row reveal offers session-scoped Configured default/general/crewcoder/plugin/extension profiles. Configured default omits the launch flag; a concrete choice adds CrewCoder's distinct `--mode`, restarts only that bridge before native resume, locks CrewCode's underlying permission policy to Build, and disables Ask/Plan/Build/Full on desktop and phone until Configured default is restored. The concrete `crewcoder` profile also reveals a session-scoped approval picker for `review`, `always`, `never`, `full-access`, and `sandboxed`. Review is the fail-closed default; Always prompts for every non-safe call, Never suppresses prompts but blocks dangerous calls, Full access accepts every call without prompting, and Sandboxed suppresses prompts while applying CrewCoder's sandbox policy to non-dangerous calls where supported. Missing or invalid values fail closed to review, changes restart only an idle bridge, and execution custody includes the native approval value. Full access explicitly bypasses CrewCoder approval requests and dangerous-command blocking, so CrewCode's permission overlay/tripwire cannot interpose. Never pass CrewCode's execution-mode value as the CrewCoder profile, and never let a concrete profile inherit a hidden prior Ask, Plan, or Full Access policy. The `crewcoder` profile's inspect/clarify/propose/approve-plan gate is CrewCoder-owned: CrewCode renders `crewcoder_clarify` and `crewcoder_propose_plan` on the activity overlay and sends `/approve-plan` as a prompt from the overlay button or slash command. Do not reuse tool-permission Allow/Deny for that card, and do not treat a clarification answer as plan approval. -CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp --approval review`. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a CrewCode-owned activity record whose pending/running/tool-phase/terminal state comes only from observed bridge events; runtime loss becomes interrupted. Optional CrewCoder `crew-tasks` snapshots on ACP `rawOutput` enrich that row while active, and newer Task* mutations fold over the current turn's last snapshot. Project-wide `TaskList` results remain excluded because they mix unrelated CrewCoder sessions. CrewCode does not prompt for or fabricate native activity and does not enable disabled CrewCoder tools. See `docs/agent-activity-overlay.md`. See `docs/crewcoder-provider.md`. +CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp` with the session's normalized native approval value. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a CrewCode-owned activity record whose pending/running/tool-phase/terminal state comes only from observed bridge events; runtime loss becomes interrupted. Optional CrewCoder `crew-tasks` snapshots on ACP `rawOutput` enrich that row while active, and newer Task* mutations fold over the current turn's last snapshot. Project-wide `TaskList` results remain excluded because they mix unrelated CrewCoder sessions. CrewCode does not prompt for or fabricate native activity and does not enable disabled CrewCoder tools. See `docs/agent-activity-overlay.md`. See `docs/crewcoder-provider.md`. ## ACP Grok Build @@ -38,7 +65,7 @@ Delegated threads let a solo-chat agent spawn real, persistent chat sessions thr ## Chat Archiving -Chat archiving (`Session.archived`) is non-destructive: archiving releases the session's bridge but must never delete its transcript, and only explicit Delete calls `transcripts:remove`. Archived sessions are hidden from every live surface because `getSessions()` filters them — use `getAllSessions()` only for the archive list itself. Activation must never land on an archived session, and a tab whose sessions are all archived is treated as empty so `ensureTab` seeds a fresh thread (with an id that skips archived ones — reusing an id would alias two threads onto one transcript). The archive/rename right-click menu lives on live drawer session rows; archived chats are not shown in the drawer at all and surface only in the `archive` tab kind, a cross-workspace review page. Archive retention (`settings.archiveRetentionDays`, `0 | 30 | 60 | 90`, default `0`) is a **classifier, not a scheduler**: it flags expired chats for an explicit confirmed bulk delete and must never gain a background sweep. A session with no `archivedAt` is never expired, legacy archived sessions are backfilled to first-launch-after-upgrade rather than zero, and a malformed persisted retention value falls back to Never — enabling a window must not retroactively mark unknown-age history deletable. See `docs/chat-archiving.md`. +Chat archiving (`Session.archived`) is non-destructive: archiving releases the session's bridge but must never delete its transcript, and only explicit Delete calls `transcripts:remove`. Archived sessions are hidden from every live surface because `getSessions()` filters them — use `getAllSessions()` only for the archive list itself. Activation must never land on an archived session, and a tab whose sessions are all archived is treated as empty so `ensureTab` seeds a fresh thread (with an id that skips archived ones — reusing an id would alias two threads onto one transcript). The archive/rename right-click menu lives on live drawer session rows; archived chats are not shown in the drawer at all and surface only in the `archive` tab kind, a cross-workspace review page. `Session.createdAt` is stamped once, while `Session.lastUsedAt` advances only when work is sent through the chat; opening, metadata edits, restore, and archive do not touch it. The archive page displays `lastUsedAt` as `MM/DD/YYYY` and sorts by it, never by archive time; legacy values are recovered from transcript mtimes when available. Archive retention (`settings.archiveRetentionDays`, `0 | 30 | 60 | 90`, default `0`) is a **classifier, not a scheduler** and continues to use the independent `archivedAt`: it flags expired chats for an explicit confirmed bulk delete and must never gain a background sweep. A session with no `archivedAt` is never expired, legacy archived sessions are backfilled to first-launch-after-upgrade rather than zero, and a malformed persisted retention value falls back to Never — enabling a window must not retroactively mark unknown-age history deletable. See `docs/chat-archiving.md`. ## Hide work Logs @@ -52,6 +79,16 @@ Solo Chat selection speech is independent text-to-speech: capture a non-empty se Voice orb start/end shortcuts are component-local keybindings exposed in Settings and `keys.json`. In split layouts, start must target the focused composer rather than fan out; end must only stop the session that currently owns the voice microphone. +## App updates + +Packaged desktop CrewCode polls GitHub Releases through `electron-updater`. +Available and downloaded versions show on the global notification bar with the +new version so the user does not have to open Settings to find them; the card +stays until dismissed and opens **Settings → Updates** on click. Channel and +auto-download policy are pushed to main at App launch, not only when the +Settings Updates section mounts. Dev builds and browser sessions do not poll. +See `docs/releasing.md` and `docs/notifications.md`. + ## Notifcation Sound Completed-turn desktop notifications use the persisted `notificationSound` setting: `system` delegates audio to the OS, `bell`/`ding`/`knock` use the renderer's synthesized tones, and `none` stays silent. Custom tones must send the native toast with `silent: true` so users never hear both CrewCode and system audio. Coalesced crew completions play exactly one sound. On Linux, toasts go through a detached `notify-send` child process — never Electron `Notification` on the main-process hot path, whose synchronous DBus round-trips froze the app 0.5–1.1s per toast. See `docs/notifications.md`. @@ -115,6 +152,10 @@ Visible chat transcripts persist in two layers (`src/renderer/src/stores/chat-me Renderer re-render isolation: state that changes at high frequency must NOT live in `App.tsx`, because `App` rebuilds the whole tab tree (and, on Workbench, every mounted `ChatPane`) on each render. Two such slices are now isolated into stores that only their consumers subscribe to — `stores/terminal-unread-store.ts` (background PTY output; a `claude`/`codex` agent in a hidden tab used to re-render the shell ~1.4×/s) and `stores/composer-draft-store.ts` (per-tab composer drafts; every keystroke re-rendered the shell). Do not move either back into `App`. Live agent state (running / status / queued follow-ups / pending user requests) lives in `stores/bridge-activity-store.ts`, not in `useBridgeRegistry`'s `useState`. It used to ride on the `bridges` prop, which meant ChatPane's Stop button, spinner, follow-up pills, and permission prompts only stayed correct because that bundle got a fresh object identity on every App render — an accident, not a design, and one no type or test protected. Consumers now subscribe to the slice they read: ChatPane uses `useIsBridgeRunning` / `useBridgeStatus` / `useQueuedFollowUps` / `useUserRequestsForTab`. `useBridgeRegistry` still subscribes to `runningByBridge` / `runningByScope` (Mission Control fans out over every session via `isBridgeRunning`), and the workspace drawer also subscribes to `runningByScope` so Running does not depend on App recomputing `workingChats`. Status and follow-up churn no longer re-render App. Do not reintroduce `getBridgeStatus` / `getQueuedFollowUps` / `userRequestsByTab` onto the `bridges` bundle. `clearBridges()` deliberately does NOT drop user requests — an idle-stopped bridge keeps its tab's requests, while `dropBridge`/`releaseTab`/`resetSession` clear them explicitly; `bridge-activity-store.test.ts` pins that. +Inactive standalone terminal tabs remain mounted for PTY/process continuity, but their full-size `visibility:hidden` keepalive containers must not keep xterm/WebGL on the renderer hot path. Pass the explicit active-tab signal through `TermColumn` to `XTermPane`; inactive panes buffer a bounded output tail without calling `term.write()`, then refit and replay with a per-frame budget plus xterm callback backpressure on activation. Visible panes use the same batching so PTY bursts do not become one renderer task per chunk. Bridge text/thinking activity phases are folded into the existing 50 ms delta flush rather than scanning the transcript for every raw provider token. See `docs/terminal-stream-performance.md`. + +Idle refreshes follow the same responsiveness contract. The five-second prompt/skill/command scan is App-owned, so unchanged content must preserve the prior library object and slow scans must stay single-flight; local scan reads use asynchronous filesystem APIs rather than blocking Electron main. The automatic GitHub poll must use asynchronous, bounded child processes and preserve the prior App state for an unchanged result. Never add `spawnSync`, `readdirSync`, `statSync`, or `readFileSync` to an interval-, focus-, or visibility-driven status path. See `docs/terminal-stream-performance.md`. + Structural message updates (tool calls and new thinking blocks) must stay isolated from the workspace shell. `Messages`' `areRowsEqual` compares a work-log anchor's actual source `ToolCallMessage` identities; never replace that with blanket live-turn invalidation, because a growing thinking/text sibling would rebuild every work log in the turn. Agent rows receive a precomputed `showTurnSummary` boolean instead of reading the whole transcript. Self-contained rows — `thinking` above all — re-render only when their own message changes. `chronologicalStreamSegments` caches splits per chunk string, so a growing block only re-splits its tail instead of every chunk on every flush (that was quadratic in turn length); it also appends units in a loop rather than `push(...units)`, which throws on very long blocks. Solo-chat auto-follow runs in the next animation frame, not `useLayoutEffect`: reading `scrollHeight` synchronously during every React commit forced shell-wide layout. Keep `.thread-shell` layout/paint-contained so transcript reflow cannot invalidate the workspace drawer. `ChatPane` is still not `React.memo`'d, but that is now a cost/benefit call rather than a correctness trap: `MessageRow` is already memoized and `Messages` pages at 50 rows, so an App-driven ChatPane re-render is cheap reconciliation. Memoize only against a profile, never against a theory. @@ -142,7 +183,7 @@ The standalone Git Workspace tab (`kind: 'git'`) is a full-page surface backed b PromptBuilder (`prompts` tab), Mission Control (`mission` tab), Canvas Mode (`canvas` tab), Code Editor, Git Workspace/Sidebar, and Changes by turn are usable on phones and tablets when CrewCode is reached through a browser context — both the standalone `crewcode serve` and the Hub-relayed `?machine=…` entry points share the same renderer. Layout decisions flow from `useMobileLayout()` (`isMobile = innerWidth ≤ 768`, `isTablet = 769–1024`) so JS branches and CSS breakpoints cannot drift. The Code Editor keeps its canvas primary with an off-canvas file tree, Git Sidebar is a dismissible right overlay from chat or editor, Git Workspace uses a bounded single-column overview/change/diff/tools flow, and Changes by turn is a full-screen stacked catalogue/diff surface. The browser context does not affect the Electron renderer; desktop behaviour is unchanged. See `docs/mobile-responsive-pages.md`. - **Hub mobile home and desktop overview**: an authenticated Hub visit at ≤768px redirects to `/app?hub=mobile` and mounts `MobileDashboard` before any Brain relay is opened. It reads the owner name and enrolled-machine presence from the Hub's cookie-authenticated, read-only session/machines endpoints; fake agent/worktree counts are forbidden. Both Hub mobile headers use the supplied theme-aware CrewCode logo assets. Selecting an online machine enters `/app?hub=mobile&machine=…` and opens a disposable end-to-end encrypted tunnel with only `workspace:read` and `agent` requested. `MobileMachineOverview` adapts Brain-visible transcript sessions and live executions into Mission agents, then uses the same `deriveMissionStats` aggregation as `mc-stats` for agents/running/done and distinct `projectId/worktree` totals; denied or incomplete inputs render as `—`. It lists at most five recent thread summaries. Current Brains return only each summary's opaque scope id, timestamp, and bounded first-user title seed—not transcript bodies. If an already-running older Brain rejects `transcripts.recent` as unsupported, the renderer falls back to `transcripts.mtimes`; saved rows still appear with timestamps and an untitled label, without downloading full histories. A recent row carries a bounded workspace/tab/session descriptor into `/app?machine=…`; after transcript hydration the App validates ownership, restores the exact session id when the browser has no local catalog entry, and focuses that thread. **Open CrewCode** enters the same renderer without a thread target. Navigation closes the overview relay before the full runtime opens. Offline machines remain disabled, and `/?hub-admin=1` is the explicit escape hatch to passkey/device administration. Desktop Hub visits, tablets above 768px, Electron, and direct `crewcode serve` startup do not mount either mobile surface. -- **PromptBuilder**: below 768px the 360px left rail and detail pane share a `list | detail` navigation state (driven by `data-view` on `.pb`). The list is edge-to-edge with `min-width: 0` enforced through its container chain; do not restore the old centered `85%` rail because it wastes phone width and lets intrinsic child widths push the page sideways. The Prompts/Skills tabs and compact New action share one bounded row. Category chips are omitted on phones; only the category-management, favorites, and layout tools remain in the compact toolbar. Prompt cards use phone-specific compact padding and type. Cards are non-shrinking children of the scrollable flex list, grow to their wrapped title and description height, and contain text overflow without line clamps. Detail mode removes the Split choice on phones: a stored desktop Split state resolves to Source, and the source textarea fills the remaining body height; users can still explicitly switch between Source and Preview. The composer PromptPicker has separate Prompts and Skills tabs: prompts insert or open variable fill, while skills toggle session-scoped activation in place without inserting their body or closing the picker. On phones the picker becomes a full-bleed bottom sheet with its side fill panel collapsed to a top section, 44px tabs, and 48px list rows. Actionable controls (icon buttons, mode toolbar, save/apply) retain ≥36px touch targets and gain `:active` states mirroring the existing `:hover` styles. Keyboard-hint chips (`⌘F`, `⌘P`, `⌘J`, `⌘S`) are hidden on phones, and text inputs stay at 16px to prevent iOS auto-zoom. +- **PromptBuilder**: above 768px the left rail uses a bounded `.pb-left` → `.pb-inner` → `.pb-list` flex-height chain so its header, filters, and footer stay fixed while prompt/skill cards scroll independently. Below 768px the 360px left rail and detail pane share a `list | detail` navigation state (driven by `data-view` on `.pb`). The list is edge-to-edge with `min-width: 0` enforced through its container chain; do not restore the old centered `85%` rail because it wastes phone width and lets intrinsic child widths push the page sideways. The Prompts/Skills tabs and compact New action share one bounded row. Category chips are omitted on phones; only the category-management, favorites, and layout tools remain in the compact toolbar. Prompt cards use phone-specific compact padding and type. Cards are non-shrinking children of the scrollable flex list, grow to their wrapped title and description height, and contain text overflow without line clamps. Detail mode removes the Split choice on phones: a stored desktop Split state resolves to Source, and the source textarea fills the remaining body height; users can still explicitly switch between Source and Preview. The composer PromptPicker has separate Prompts and Skills tabs: prompts insert or open variable fill, while skills toggle session-scoped activation in place without inserting their body or closing the picker. On phones the picker becomes a full-bleed bottom sheet with its side fill panel collapsed to a top section, 44px tabs, and 48px list rows. Actionable controls (icon buttons, mode toolbar, save/apply) retain ≥36px touch targets and gain `:active` states mirroring the existing `:hover` styles. Keyboard-hint chips (`⌘F`, `⌘P`, `⌘J`, `⌘S`) are hidden on phones, and text inputs stay at 16px to prevent iOS auto-zoom. - **Mission Control**: `deriveMissionStats` is the canonical aggregation for the desktop `mc-stats` strip, hero totals, and Hub mobile machine overview; status counts and distinct `projectId/worktree` identity must not be reimplemented per surface. Below 768px the activity feed moves out of the page into a new `MobileShell` sheet (`mission-activity`) driven by a header "Activity" pill in the toolbar. `StatStrip` collapses from 6 to 3 columns (and to 2 below 480px), the agent grid floor drops to 280px so cards fit at 360px viewport width, the `BlockingBanner` row reflows into two rows (body on top, reply input + jump button on the bottom), and the menulet already had a 768px full-bleed override. `pluginMissionWidgets` and the in-page side feed are hidden on phones and live behind the sheet. The toolbar is a two-row stack: filter segments alone on the top row, all action controls (Activity pill, group-by select, refresh, spawn) on a second row pushed to the right with the desktop spacer collapsed. - **Canvas Mode**: below 768px the pane grid `grid-auto-rows` drops to `minmax(min(50dvh, 480px), 1fr)` so two panes can share a phone screen. The pane-bar action cluster is replaced by a single `⋯` overflow button (`.canvas-mode-pane-more`) that opens a popover menu (`.canvas-mode-pane-menu-pop`) carrying the per-pane actions: mode prompt toggle (chat only), verbose-logs toggle (chat only), and close pane; Add chat / Add terminal live on the page-level FAB. The popover closes on outside click, touch, and Escape. The "Add" controls inside the desktop cluster were promoted from `
` to `
) })} diff --git a/src/renderer/src/components/archive/ArchivePage.tsx b/src/renderer/src/components/archive/ArchivePage.tsx index 199832a..27cd02b 100644 --- a/src/renderer/src/components/archive/ArchivePage.tsx +++ b/src/renderer/src/components/archive/ArchivePage.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState } from 'react' import type { Session, Workspace } from '../../types' import { ARCHIVE_RETENTION_CHOICES, type ArchiveRetentionDays } from '../../hooks/useSettings' -import { formatArchivedAgo, isExpired, retentionLabel } from '../../hooks/archive-retention' +import { formatLastUsedDate, isExpired, retentionLabel } from '../../hooks/archive-retention' import { PROVIDER_IMAGES, providerImageClass } from '../composer/provider-meta' import { ConfirmModal, type ConfirmModalRequest } from '../ui/ConfirmModal' import { Icon } from '../ui/Icon' @@ -50,8 +50,8 @@ export function ArchivePage({ return entries .filter(e => !wsFilter || e.wsId === wsFilter) .filter(e => !q || e.session.label.toLowerCase().includes(q) || e.wsName.toLowerCase().includes(q)) - // Most recently archived first; unknown timestamps sink to the bottom. - .sort((a, b) => (b.session.archivedAt ?? 0) - (a.session.archivedAt ?? 0)) + // Most recently used first; filing a chat away must not affect its order. + .sort((a, b) => (b.session.lastUsedAt ?? b.session.createdAt ?? 0) - (a.session.lastUsedAt ?? a.session.createdAt ?? 0)) }, [entries, query, wsFilter]) const confirmDeleteOne = (entry: ArchivedEntry) => { @@ -161,7 +161,7 @@ export function ArchivePage({ {entry.session.label} {entry.wsName} - archived {formatArchivedAgo(entry.session, now)} + {formatLastUsedDate(entry.session)} {gone && expired}
diff --git a/src/renderer/src/components/chat/ChatPane.tsx b/src/renderer/src/components/chat/ChatPane.tsx index 9f5f8bc..48888ce 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 { crewCoderProfileLocksExecutionMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' +import { crewCoderApprovalForProfile, crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' type CrewBranchWithMessagesProps = Omit, 'messagesByTab'> @@ -305,6 +305,9 @@ 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) const crewCoderProfileActive = crewCoderProfileLocksExecutionMode(activeAgentId, crewcoderMode) const modeLevel = crewCoderProfileActive ? 'build' : normalizeModeLevel(activeSession?.mode ?? settingsDefaultMode) const composerMode: Mode = MODE_FROM_SETTINGS[modeLevel] ?? 'Build' @@ -379,9 +382,14 @@ export function ChatPane({ chatSessions.update(tabId, sessActive, { crewcoderMode: nextMode, ...(nextMode ? { mode: 'build' as const } : {}), + ...(nextMode === 'crewcoder' ? {} : { crewcoderApprovalMode: 'review' as const }), }) }, [tabId, sessActive, chatSessions]) + const setCrewCoderApprovalMode = useCallback((nextMode: CrewCoderApprovalMode) => { + chatSessions.update(tabId, sessActive, { crewcoderApprovalMode: nextMode }) + }, [tabId, sessActive, chatSessions]) + const setComposerMode = useCallback((m: Mode) => { if (crewCoderProfileActive) return chatSessions.update(tabId, sessActive, { mode: MODE_TO_LEVEL[m] }) @@ -455,6 +463,7 @@ export function ChatPane({ model, effort, crewcoderMode, + crewcoderApprovalMode, mode: modeLevel, effectivePath, bridges, @@ -478,6 +487,7 @@ export function ChatPane({ workspaceName: workspace.name, workspaceBranch: worktreeBranch ?? effectiveBranch, externalDirectories: activeSession?.externalDirectories ?? [], + onSessionUsed: () => chatSessions.touchLastUsed(tabId, sessActive), }) const sendWithSessionTitle = useCallback(async (overrideText?: string) => { @@ -621,6 +631,7 @@ export function ChatPane({ setHandoffError(null) setHandoffOpen(false) chatSessions.activate(target.tabId, target.id) + chatSessions.touchLastUsed(target.tabId, target.id) onHandoffDestinationActivate(target) const targetAgent = agents.find(agent => agent.id === target!.agentId) @@ -863,6 +874,8 @@ export function ChatPane({ setEffort={setEffort} crewcoderMode={crewcoderMode} setCrewCoderMode={setCrewCoderMode} + crewcoderApprovalMode={crewcoderApprovalMode} + setCrewCoderApprovalMode={setCrewCoderApprovalMode} delegationEnabled={delegation.enabled} onToggleDelegation={canSessionDelegate(activeSession) ? toggleDelegation : undefined} modePromptsEnabled={modePromptsEnabled} diff --git a/src/renderer/src/components/chat/SoloChatView.tsx b/src/renderer/src/components/chat/SoloChatView.tsx index 3d9c6d1..653e0ff 100644 --- a/src/renderer/src/components/chat/SoloChatView.tsx +++ b/src/renderer/src/components/chat/SoloChatView.tsx @@ -21,7 +21,7 @@ import type { EffortLevel } from '../composer/EffortPicker' import type { McpServerConfig } from '../../hooks/useSettings' import type { VoiceControlSurface } from '../../../../shared/voice-types' import type { TurnChangeTarget } from '../thread/turn-changes-data' -import type { CrewCoderMode } from '../../../../shared/crewcoder-types' +import type { CrewCoderApprovalMode, CrewCoderMode } from '../../../../shared/crewcoder-types' type ThreadView = 'chat' | 'code' | 'md' @@ -89,6 +89,8 @@ export interface SoloChatViewProps { setEffort: (e: EffortLevel) => void crewcoderMode?: CrewCoderMode setCrewCoderMode: (mode: CrewCoderMode | undefined) => void + crewcoderApprovalMode: CrewCoderApprovalMode + setCrewCoderApprovalMode: (mode: CrewCoderApprovalMode) => void // MCP — registry + this session's opt-in selection. Picker hidden when disabled. mcpEnabled?: boolean mcpServers?: McpServerConfig[] @@ -151,7 +153,7 @@ export function SoloChatView(props: SoloChatViewProps) { agentLabel, modelLabel, voiceControl, gitOpen, setGitOpen, github, dirtyCount = 0, changesOpen, changesCount, toggleChangesOpen, onStartCrew, onOpenCanvas, onOpenTerminal, onHandoff, composerMode, setComposerMode, composer, setComposer, onSend, onRunCommand, onQueueFollowUp, queuedFollowUps = [], onRemoveQueuedFollowUp, isRunning, loadingStatus = null, onStop, agentRequest, custodyHalt, onReauthorizeCustody, onAgentRequestResponse, - agents, activeAgentId, setActiveAgentId, model, setModel, effort, setEffort, crewcoderMode, setCrewCoderMode, + agents, activeAgentId, setActiveAgentId, model, setModel, effort, setEffort, crewcoderMode, setCrewCoderMode, crewcoderApprovalMode, setCrewCoderApprovalMode, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, shortcutOverrides, onOpenFile, onOpenTurnChange, editorInitialFile, onThreadContextMenu, onOpenPrompts, onOpenBrowser, delegationEnabled, onToggleDelegation, @@ -269,6 +271,8 @@ export function SoloChatView(props: SoloChatViewProps) { onSelectEffort={setEffort} crewcoderMode={crewcoderMode} onSelectCrewCoderMode={setCrewCoderMode} + crewcoderApprovalMode={crewcoderApprovalMode} + onSelectCrewCoderApprovalMode={setCrewCoderApprovalMode} mcpEnabled={mcpEnabled} mcpServers={mcpServers} selectedMcpIds={selectedMcpIds} diff --git a/src/renderer/src/components/composer/Composer.tsx b/src/renderer/src/components/composer/Composer.tsx index b9c67a4..a9b3fc6 100644 --- a/src/renderer/src/components/composer/Composer.tsx +++ b/src/renderer/src/components/composer/Composer.tsx @@ -16,7 +16,7 @@ import { ComposerBranchPicker } from '../git/BranchPicker' import type { GitBranchRef } from '../git/git-state' import { VoiceOrb } from '../voice/VoiceOrb' import type { VoiceControlSurface } from '../../../../shared/voice-types' -import { crewCoderProfileLocksExecutionMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' +import { crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' import { ComposerDictationButton } from './ComposerDictationButton' import { insertDictationText } from './composer-dictation-text' import { MobileComposerActionMenu, MobileComposerModelMenu } from './MobileComposerMenus' @@ -69,6 +69,8 @@ interface ComposerProps { onSelectEffort: (e: EffortLevel) => void crewcoderMode?: CrewCoderMode onSelectCrewCoderMode: (mode: CrewCoderMode | undefined) => void + crewcoderApprovalMode: CrewCoderApprovalMode + onSelectCrewCoderApprovalMode: (mode: CrewCoderApprovalMode) => void mcpEnabled?: boolean mcpServers?: McpServerConfig[] @@ -160,7 +162,7 @@ export function Composer({ sentMessageHistory = [], isRunning, onStop, voiceControl, dictationScopeId, agents, activeAgentId, onSelectAgent, - model, onSelectModel, effort, onSelectEffort, crewcoderMode, onSelectCrewCoderMode, + model, onSelectModel, effort, onSelectEffort, crewcoderMode, onSelectCrewCoderMode, crewcoderApprovalMode, onSelectCrewCoderApprovalMode, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, shortcutOverrides, attachments: attachmentsProp, onAttachmentsChange, @@ -742,6 +744,8 @@ export function Composer({ effort={effort} crewcoderMode={crewcoderMode} onSelectCrewCoderMode={onSelectCrewCoderMode} + crewcoderApprovalMode={crewcoderApprovalMode} + onSelectCrewCoderApprovalMode={onSelectCrewCoderApprovalMode} crewcoderModeDisabled={isRunning} executionModeDisabled={executionModeDisabled} mode={mode} diff --git a/src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts b/src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts new file mode 100644 index 0000000..a065fa4 --- /dev/null +++ b/src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { crewCoderApprovalItems } from './CrewCoderApprovalPicker' + +describe('CrewCoder approval picker', () => { + it('exposes every CrewCoder approval policy in stable order', () => { + expect(crewCoderApprovalItems().map(item => item.id)).toEqual([ + 'review', 'always', 'never', 'full-access', 'sandboxed', + ]) + }) +}) diff --git a/src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx b/src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx new file mode 100644 index 0000000..c5564ec --- /dev/null +++ b/src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { Icon } from '../ui/Icon' +import { PickerSheet } from './PickerSheet' +import type { CrewCoderApprovalMode } from '../../../../shared/crewcoder-types' + +export function crewCoderApprovalItems() { + return [ + { id: 'review', label: 'Review', sub: 'Safe calls proceed; mutations and dangerous calls ask' }, + { id: 'always', label: 'Always', sub: 'Every non-safe call asks for permission' }, + { id: 'never', label: 'Never', sub: 'No prompts; dangerous calls remain blocked' }, + { id: 'full-access', label: 'Full access', sub: 'No prompts; all calls are accepted' }, + { id: 'sandboxed', label: 'Sandboxed', sub: 'No prompts; non-dangerous calls use the sandbox policy' }, + ] satisfies Array<{ id: CrewCoderApprovalMode; label: string; sub: string }> +} + +interface CrewCoderApprovalPickerProps { + open: boolean + anchor: HTMLElement | null + value: CrewCoderApprovalMode + onPick: (mode: CrewCoderApprovalMode) => void + onClose: () => void +} + +export function CrewCoderApprovalPicker({ open, anchor, value, onPick, onClose }: CrewCoderApprovalPickerProps) { + return ( + onPick(id as CrewCoderApprovalMode)} + defaultIcon={} + width={340} + /> + ) +} diff --git a/src/renderer/src/components/composer/ModelRow.test.ts b/src/renderer/src/components/composer/ModelRow.test.ts index cffc6a3..0d06a22 100644 --- a/src/renderer/src/components/composer/ModelRow.test.ts +++ b/src/renderer/src/components/composer/ModelRow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentInfo } from '../../types' -import { crewCoderModesAvailable } from './ModelRow' +import { crewCoderApprovalAvailable, crewCoderModesAvailable } from './ModelRow' const crewcoder = (available: boolean): AgentInfo => ({ id: 'crewcoder', name: 'CrewCoder', path: available ? '/usr/bin/crewcoder' : null, @@ -13,4 +13,11 @@ describe('CrewCoder model-row control', () => { expect(crewCoderModesAvailable([crewcoder(false)], 'crewcoder')).toBe(false) 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) + }) }) diff --git a/src/renderer/src/components/composer/ModelRow.tsx b/src/renderer/src/components/composer/ModelRow.tsx index 5a18a52..c3cd10a 100644 --- a/src/renderer/src/components/composer/ModelRow.tsx +++ b/src/renderer/src/components/composer/ModelRow.tsx @@ -12,7 +12,8 @@ import type { McpServerConfig } from '../../hooks/useSettings' import type { Mode } from './ModeSegment' import { ModeSegment } from './ModeSegment' import { CrewCoderModePicker } from './CrewCoderModePicker' -import type { CrewCoderMode } from '../../../../shared/crewcoder-types' +import { CrewCoderApprovalPicker } from './CrewCoderApprovalPicker' +import type { CrewCoderApprovalMode, CrewCoderMode } from '../../../../shared/crewcoder-types' interface ModelRowProps { agents: AgentInfo[] @@ -28,6 +29,8 @@ interface ModelRowProps { setMode: (m: Mode) => void crewcoderMode?: CrewCoderMode onSelectCrewCoderMode: (mode: CrewCoderMode | undefined) => void + crewcoderApprovalMode: CrewCoderApprovalMode + onSelectCrewCoderApprovalMode: (mode: CrewCoderApprovalMode) => void crewcoderModeDisabled?: boolean executionModeDisabled?: boolean @@ -64,8 +67,12 @@ 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 const ModelRow = forwardRef(function ModelRow({ - agents, setMode, mode, crewcoderMode, onSelectCrewCoderMode, crewcoderModeDisabled = false, executionModeDisabled = false, activeAgentId, onSelectAgent, + agents, setMode, mode, crewcoderMode, onSelectCrewCoderMode, crewcoderApprovalMode, onSelectCrewCoderApprovalMode, crewcoderModeDisabled = false, executionModeDisabled = false, activeAgentId, onSelectAgent, model, onSelectModel, effort, onSelectEffort, mcpEnabled = false, mcpServers = [], selectedMcpIds = [], onToggleMcp, @@ -76,19 +83,24 @@ export const ModelRow = forwardRef(function Model const effortRef = useRef(null) const mcpRef = useRef(null) const crewCoderModeRef = useRef(null) + const crewCoderApprovalRef = useRef(null) const [provOpen, setProvOpen] = useState(false) const [modelOpen, setModelOpen] = useState(false) const [effortOpen, setEffortOpen] = useState(false) const [mcpOpen, setMcpOpen] = useState(false) const [crewCoderModeOpen, setCrewCoderModeOpen] = useState(false) + const [crewCoderApprovalOpen, setCrewCoderApprovalOpen] = useState(false) useEffect(() => { - onOpenChange?.(provOpen || modelOpen || effortOpen || mcpOpen || crewCoderModeOpen) - }, [provOpen, modelOpen, effortOpen, mcpOpen, crewCoderModeOpen, onOpenChange]) + onOpenChange?.(provOpen || modelOpen || effortOpen || mcpOpen || crewCoderModeOpen || crewCoderApprovalOpen) + }, [provOpen, modelOpen, effortOpen, mcpOpen, crewCoderModeOpen, crewCoderApprovalOpen, onOpenChange]) useEffect(() => { - if (crewcoderModeDisabled) setCrewCoderModeOpen(false) + if (crewcoderModeDisabled) { + setCrewCoderModeOpen(false) + setCrewCoderApprovalOpen(false) + } }, [crewcoderModeDisabled]) // Only count selections that still exist in the registry, so a removed server @@ -99,7 +111,7 @@ export const ModelRow = forwardRef(function Model const { list: models } = useProviderModels(activeAgentId) useImperativeHandle(ref, () => ({ - openModelPicker: () => { setModelOpen(true); setProvOpen(false); setEffortOpen(false); setCrewCoderModeOpen(false) }, + openModelPicker: () => { setModelOpen(true); setProvOpen(false); setEffortOpen(false); setCrewCoderModeOpen(false); setCrewCoderApprovalOpen(false) }, cycleModel: (dir: 1 | -1) => { if (models.length === 0) return const idx = models.findIndex(m => m.id === model) @@ -111,6 +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 handleSelectAgent = (id: string) => { // Start resolving the next provider immediately so the model picker is warm. @@ -125,7 +138,7 @@ export const ModelRow = forwardRef(function Model )} + {showCrewCoderApproval && ( + + )} +
+ {typeof window.electronAPI?.trayConfigure === 'function' &&
+
+
Keep running in background
+
When you close the CrewCode window, keep terminals and agents running and reopen the app from the system tray. Use Quit CrewCode in the tray menu to exit fully.
+
+ set('keepRunningInBackground', v)} /> +
}
Layout panel
@@ -485,6 +493,78 @@ function GeneralSection({ state, set, workspace }: { state: SettingsState; set: ) } +function BrainContinuitySection() { + const [status, setStatus] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + const refresh = useCallback(() => { + void window.electronAPI?.brainDesktopStatus(true).then(setStatus).catch(cause => setError((cause as Error).message)) + }, []) + useEffect(refresh, [refresh]) + + const enable = async () => { + setBusy(true); setError('') + try { + const next = await window.electronAPI!.brainDesktopSetEnabled(true) + setStatus(next) + if (next.attached) window.location.reload() + } catch (cause) { setError((cause as Error).message) } + finally { setBusy(false) } + } + const stop = async () => { + setBusy(true); setError('') + try { + setStatus(await window.electronAPI!.brainDesktopStop()) + window.location.reload() + } catch (cause) { setError((cause as Error).message) } + finally { setBusy(false) } + } + const openHub = async () => { + if (!status?.hubBrowserOrigin) return + setError('') + try { + const result = await window.electronAPI!.openExternal(status.hubBrowserOrigin) + if (!result.ok) setError('CrewCode could not open the Hub browser URL.') + } catch (cause) { setError((cause as Error).message) } + } + + const loopbackHub = status?.hubBrowserOrigin + ? ['localhost', '127.0.0.1', '::1'].includes(new URL(status.hubBrowserOrigin).hostname) + : false + + return ( +
+
+

Desktop & Web

+ Brain continuity +
+
+
+
+
Background Brain
+
Make this machine's Brain authoritative for workspaces, conversations, terminals, and agents. Closing the desktop window leaves enrolled 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 ? ( + <> +
Hub browser · {status.hubBrowserOrigin}{loopbackHub ? ' · this PC only' : ''}
+
The Hub web server is separate from the Brain and must be running and reachable at this address.
+ + ) : status?.hubOrigin ?
Hub browser · {status.hubReachable === false ? 'not reachable' : 'origin unavailable'} · enrolled through {status.hubOrigin}
: null} + {(error || status?.error) ?
{error || status?.error}
: null} +
+
+ {status?.hubBrowserOrigin ? : null} + {status?.running || status?.enabled + ? + : } +
+
+
+
+ ) +} + /* ---------- Section: Mode prompts ---------- */ const MODE_PROMPT_OPTIONS: SegOption[] = [ @@ -2254,7 +2334,9 @@ export function SettingsScreen({ activeWorkspace }: { activeWorkspace?: Workspac ? SECTIONS.map(group => group.group === 'connectivity' ? { ...group, items: [...group.items, { id: 'brain-authorization', label: 'Brain Access', icon: 'server' as IconName }] } : group) - : SECTIONS, [webRuntime]) + : SECTIONS.map(group => group.group === 'connectivity' + ? { ...group, items: [{ id: 'brain-continuity', label: 'Desktop & Web', icon: 'server' as IconName }, ...group.items] } + : group), [webRuntime]) const [query, setQuery] = useState('') const searchRef = useRef(null) @@ -2325,6 +2407,26 @@ export function SettingsScreen({ activeWorkspace }: { activeWorkspace?: Workspac if (sec && root) root.scrollTo({ top: sec.offsetTop - 12, behavior: 'smooth' }) } + // Honor an updater-bar (or other) request to land on a specific section, + // whether Settings was already open or just mounted. + useEffect(() => { + const apply = (id: string) => { + const run = () => scrollTo(id) + run() + requestAnimationFrame(run) + } + const pending = takePendingSettingsSection() + if (pending) apply(pending) + const onEvent = (event: Event) => { + const id = (event as CustomEvent).detail + if (typeof id !== 'string' || !id.trim()) return + takePendingSettingsSection() + apply(id) + } + window.addEventListener(SETTINGS_SECTION_EVENT, onEvent) + return () => window.removeEventListener(SETTINGS_SECTION_EVENT, onEvent) + }, []) + useEffect(() => { const fn = (e: KeyboardEvent) => { const mod = IS_MAC ? e.metaKey : e.ctrlKey @@ -2425,6 +2527,7 @@ export function SettingsScreen({ activeWorkspace }: { activeWorkspace?: Workspac {webRuntime && } + {!webRuntime && } diff --git a/src/renderer/src/components/settings/brain-continuity-settings.test.ts b/src/renderer/src/components/settings/brain-continuity-settings.test.ts new file mode 100644 index 0000000..ee38570 --- /dev/null +++ b/src/renderer/src/components/settings/brain-continuity-settings.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +describe('Desktop & Web continuity settings placement', () => { + it('exposes Background Brain only on desktop Settings, with Stop Brain withdrawing availability', () => { + const settings = readFileSync(join(__dirname, 'SettingsScreen.tsx'), 'utf8') + const menu = readFileSync(join(__dirname, '../ui/AppMenu.tsx'), 'utf8') + + expect(settings).toContain("id: 'brain-continuity', label: 'Desktop & Web'") + expect(settings).toContain('{!webRuntime && }') + expect(settings).toContain('id="brain-continuity" className="ss-section"') + expect(settings).toContain('brainDesktopStatus(true)') + expect(settings).toContain('Hub browser · {status.hubBrowserOrigin}') + expect(settings).toContain('The Hub web server is separate from the Brain') + expect(settings).toContain('Open Hub') + expect(settings).toContain('openExternal(status.hubBrowserOrigin)') + expect(settings).toContain('Stop Brain') + expect(menu).toContain("label: 'Quit and stop Brain'") + expect(menu).toContain("it.id !== 'quit-stop-brain' || isBrain") + }) +}) diff --git a/src/renderer/src/components/settings/settings-section-focus.test.ts b/src/renderer/src/components/settings/settings-section-focus.test.ts new file mode 100644 index 0000000..ba0ee7b --- /dev/null +++ b/src/renderer/src/components/settings/settings-section-focus.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + requestSettingsSection, + takePendingSettingsSection, +} from './settings-section-focus' + +afterEach(() => { + takePendingSettingsSection() +}) + +describe('settings section focus', () => { + it('stores a pending section so Settings can scroll after it mounts', () => { + requestSettingsSection('updates') + expect(takePendingSettingsSection()).toBe('updates') + expect(takePendingSettingsSection()).toBeNull() + }) + + it('ignores blank ids', () => { + requestSettingsSection(' ') + expect(takePendingSettingsSection()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/settings-section-focus.ts b/src/renderer/src/components/settings/settings-section-focus.ts new file mode 100644 index 0000000..1e163c9 --- /dev/null +++ b/src/renderer/src/components/settings/settings-section-focus.ts @@ -0,0 +1,20 @@ +export const SETTINGS_SECTION_EVENT = 'crewcode:settings-section' + +let pendingSection: string | null = null + +/** Remember a Settings section to scroll into view. Safe to call before the + * Settings tab is mounted: the screen consumes the pending id on mount, and + * an already-open screen also hears the window event. */ +export function requestSettingsSection(id: string): void { + const section = id.trim() + if (!section) return + pendingSection = section + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent(SETTINGS_SECTION_EVENT, { detail: section })) +} + +export function takePendingSettingsSection(): string | null { + const id = pendingSection + pendingSection = null + return id +} diff --git a/src/renderer/src/components/terminal/TermColumn.tsx b/src/renderer/src/components/terminal/TermColumn.tsx index 58fa8c1..84a0e5f 100644 --- a/src/renderer/src/components/terminal/TermColumn.tsx +++ b/src/renderer/src/components/terminal/TermColumn.tsx @@ -30,6 +30,8 @@ export interface TermColumnProps { agents: AgentInfo[] /** Window-tab kind so XTermPane's reattach `ptyCreate` keeps YuHeard flags. */ tabKind?: string + /** False for mounted keepalive tabs; their xterms buffer without rendering. */ + active?: boolean onClose: (paneId: string) => void onAddShell: () => PtyPane onAddAgent: (agentId: string) => PtyPane | undefined @@ -65,7 +67,7 @@ const PANE_MENU_BASE: ChatContextMenuItem[] = [ ] export function TermColumn({ - panes, agents, tabKind, onClose, onAddShell, onAddAgent, onAddSsh, sshTargets = [], + panes, agents, tabKind, active = true, onClose, onAddShell, onAddAgent, onAddSsh, sshTargets = [], layout: externalLayout, onLayoutChange, onOpenUrl, pluginTerminalWatchers = [], onPluginTerminalWatcher, onSessionDrop, }: TermColumnProps) { @@ -408,6 +410,7 @@ export function TermColumn({ a.id === p.agentId)?.path ?? undefined) : undefined)} argv={p.argv} collapsed={paneCollapsed} diff --git a/src/renderer/src/components/terminal/XTermPane.tsx b/src/renderer/src/components/terminal/XTermPane.tsx index 352c380..f1b4ca4 100644 --- a/src/renderer/src/components/terminal/XTermPane.tsx +++ b/src/renderer/src/components/terminal/XTermPane.tsx @@ -6,6 +6,7 @@ import type { ITerminalAddon } from '@xterm/xterm' import '@xterm/xterm/css/xterm.css' import { monoFontStack, resolveTerminalFont, type Gpu } from '../../hooks/useSettings' +import { TerminalOutputBuffer } from './terminal-output-buffer' // Browsers hard-cap live WebGL contexts (~16 in Chromium); past that the oldest // context is force-lost, which cascades into contextLost→dispose churn across @@ -109,6 +110,7 @@ interface XTermPaneProps { shell?: string argv?: string[] env?: Record + active?: boolean collapsed?: boolean onCollapsedChange?: (collapsed: boolean) => void onExit?: (exitCode: number) => void @@ -120,11 +122,12 @@ interface XTermPaneProps { } -export function XTermPane({ pane, tabKind, shell, argv, env, collapsed = false, onCollapsedChange, onExit, onClose, onOpenUrl, onHeaderDragStart, onHeaderDragEnd, onClipboardActionsChange }: XTermPaneProps) { +export function XTermPane({ pane, tabKind, shell, argv, env, active = true, collapsed = false, onCollapsedChange, onExit, onClose, onOpenUrl, onHeaderDragStart, onHeaderDragEnd, onClipboardActionsChange }: XTermPaneProps) { const hostRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) const rendererRef = useRef(null) + const outputBufferRef = useRef(null) const [live, setLive] = useState(pane.live) const { state: settings } = useSettings() @@ -209,11 +212,13 @@ export function XTermPane({ pane, tabKind, shell, argv, env, collapsed = false, let disposed = false // Routed subscription: this pane only receives its own output, so a busy // sibling terminal no longer runs a callback here per chunk. - const offData = api.onPtyDataForPane(pane.paneId, (data) => term.write(data)) + const outputBuffer = new TerminalOutputBuffer((data, done) => term.write(data, done), { active: false }) + outputBufferRef.current = outputBuffer + const offData = api.onPtyDataForPane(pane.paneId, (data) => outputBuffer.enqueue(data)) const offExit = api.onPtyExit(({ paneId, exitCode }) => { if (paneId !== pane.paneId) return setLive(false) - term.write(`\r\n\x1b[2;37m[process exited: ${exitCode}]\x1b[0m\r\n`) + outputBuffer.enqueue(`\r\n\x1b[2;37m[process exited: ${exitCode}]\x1b[0m\r\n`) onExitRef.current?.(exitCode) }) @@ -238,7 +243,7 @@ export function XTermPane({ pane, tabKind, shell, argv, env, collapsed = false, setLive(false) return } - if (result.buffer) term.write(result.buffer) + if (result.buffer) outputBuffer.enqueue(result.buffer) setLive(true) }) @@ -301,6 +306,8 @@ export function XTermPane({ pane, tabKind, shell, argv, env, collapsed = false, onResize.dispose() linkProvider.dispose() ro.disconnect() + outputBuffer.dispose() + outputBufferRef.current = null // Unmounts happen on tab switches and layout changes; keep the PTY alive // so open tabs retain shell/agent state until the user explicitly closes. onClipboardActionsChangeRef.current?.(pane.paneId, null) @@ -312,6 +319,25 @@ export function XTermPane({ pane, tabKind, shell, argv, env, collapsed = false, } }, [pane.paneId, collapsed, shell, argv, env]) + // Terminal tabs intentionally stay mounted so their PTYs survive navigation. + // Keep inactive xterms completely off the renderer hot path; when the tab is + // selected, refit first and then drain the bounded output tail over frames. + useEffect(() => { + const outputBuffer = outputBufferRef.current + if (!outputBuffer) return + if (!active) { + outputBuffer.setActive(false) + return + } + const frame = requestAnimationFrame(() => { + if (hostRef.current && hostHasUsableSize(hostRef.current)) { + try { fitRef.current?.fit() } catch { /* host detached */ } + } + outputBuffer.setActive(true) + }) + return () => cancelAnimationFrame(frame) + }, [active, collapsed]) + // Apply Typography settings to a live terminal without recreating it. // Refits afterwards so cols/rows stay accurate. useEffect(() => { diff --git a/src/renderer/src/components/terminal/terminal-output-buffer.test.ts b/src/renderer/src/components/terminal/terminal-output-buffer.test.ts new file mode 100644 index 0000000..ae79b96 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-output-buffer.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' + +import { TerminalOutputBuffer } from './terminal-output-buffer' + +function harness(options: { active?: boolean; frameBudget?: number; pendingLimit?: number } = {}) { + const frames = new Map() + const writes: Array<{ data: string; done: () => void }> = [] + let nextFrame = 1 + const queue = new TerminalOutputBuffer( + (data, done) => writes.push({ data, done }), + { + ...options, + scheduleFrame: callback => { + const id = nextFrame++ + frames.set(id, callback) + return id + }, + cancelFrame: id => { frames.delete(id) }, + }, + ) + const runFrame = () => { + const entry = frames.entries().next().value as [number, FrameRequestCallback] | undefined + if (!entry) return false + frames.delete(entry[0]) + entry[1](0) + return true + } + return { queue, frames, writes, runFrame } +} + +describe('TerminalOutputBuffer', () => { + it('coalesces output and waits for xterm backpressure before scheduling more', () => { + const h = harness({ frameBudget: 5 }) + h.queue.enqueue('abc') + h.queue.enqueue('defgh') + expect(h.frames.size).toBe(1) + + h.runFrame() + expect(h.writes.map(write => write.data)).toEqual(['abcde']) + expect(h.frames.size).toBe(0) + + h.writes[0]!.done() + expect(h.frames.size).toBe(1) + h.runFrame() + expect(h.writes.map(write => write.data)).toEqual(['abcde', 'fgh']) + }) + + it('does no xterm work while inactive and drains after activation', () => { + const h = harness({ active: false }) + h.queue.enqueue('background output') + expect(h.frames.size).toBe(0) + expect(h.writes).toHaveLength(0) + + h.queue.setActive(true) + expect(h.frames.size).toBe(1) + h.runFrame() + expect(h.writes[0]?.data).toBe('background output') + }) + + it('bounds hidden output and reports omitted characters', () => { + const h = harness({ active: false, frameBudget: 8, pendingLimit: 8 }) + h.queue.enqueue('123456') + h.queue.enqueue('7890') + h.queue.setActive(true) + h.runFrame() + + expect(h.writes[0]?.data).toContain('2 characters of hidden terminal output omitted') + h.writes[0]!.done() + h.runFrame() + expect(h.writes.slice(1).map(write => write.data).join('')).toBe('34567890') + }) + + it('cancels scheduled work on dispose', () => { + const h = harness() + h.queue.enqueue('pending') + const cancel = vi.spyOn(h.frames, 'delete') + h.queue.dispose() + expect(cancel).toHaveBeenCalled() + expect(h.frames.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-output-buffer.ts b/src/renderer/src/components/terminal/terminal-output-buffer.ts new file mode 100644 index 0000000..c9c7f85 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-output-buffer.ts @@ -0,0 +1,136 @@ +const DEFAULT_FRAME_BUDGET = 64 * 1024 +const DEFAULT_PENDING_LIMIT = 2_000_000 + +type ScheduleFrame = (callback: FrameRequestCallback) => number +type CancelFrame = (handle: number) => void + +interface TerminalOutputBufferOptions { + active?: boolean + frameBudget?: number + pendingLimit?: number + scheduleFrame?: ScheduleFrame + cancelFrame?: CancelFrame +} + +/** + * Frame-budgeted, backpressured output queue for xterm. + * + * Inactive terminal tabs stay mounted so their PTYs survive tab switches, but + * xterm must not keep parsing and painting their output in the background. The + * queue retains a bounded tail while inactive and drains it over animation + * frames after activation. Waiting for xterm's write callback prevents a fast + * PTY from building a second unbounded parser queue inside xterm. + */ +export class TerminalOutputBuffer { + private chunks: string[] = [] + private headOffset = 0 + private pendingChars = 0 + private droppedChars = 0 + private active: boolean + private disposed = false + private writing = false + private frame: number | null = null + + private readonly frameBudget: number + private readonly pendingLimit: number + private readonly scheduleFrame: ScheduleFrame + private readonly cancelFrame: CancelFrame + + constructor( + private readonly write: (data: string, done: () => void) => void, + options: TerminalOutputBufferOptions = {}, + ) { + this.active = options.active ?? true + this.frameBudget = Math.max(1, options.frameBudget ?? DEFAULT_FRAME_BUDGET) + this.pendingLimit = Math.max(this.frameBudget, options.pendingLimit ?? DEFAULT_PENDING_LIMIT) + this.scheduleFrame = options.scheduleFrame ?? ((callback) => requestAnimationFrame(callback)) + this.cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle)) + } + + enqueue(data: string): void { + if (this.disposed || !data) return + this.chunks.push(data) + this.pendingChars += data.length + this.trimPendingTail() + this.schedule() + } + + setActive(active: boolean): void { + if (this.disposed || this.active === active) return + this.active = active + if (!active && this.frame !== null) { + this.cancelFrame(this.frame) + this.frame = null + } + if (active) this.schedule() + } + + dispose(): void { + this.disposed = true + if (this.frame !== null) this.cancelFrame(this.frame) + this.frame = null + this.chunks = [] + this.headOffset = 0 + this.pendingChars = 0 + this.droppedChars = 0 + } + + private trimPendingTail(): void { + let excess = this.pendingChars - this.pendingLimit + while (excess > 0 && this.chunks.length > 0) { + const available = this.chunks[0]!.length - this.headOffset + const remove = Math.min(excess, available) + this.headOffset += remove + this.pendingChars -= remove + this.droppedChars += remove + excess -= remove + if (this.headOffset === this.chunks[0]!.length) { + this.chunks.shift() + this.headOffset = 0 + } + } + } + + private schedule(): void { + if (this.disposed || !this.active || this.writing || this.frame !== null || this.pendingChars === 0) return + this.frame = this.scheduleFrame(() => { + this.frame = null + this.drainFrame() + }) + } + + private drainFrame(): void { + if (this.disposed || !this.active || this.writing) return + let budget = this.frameBudget + const parts: string[] = [] + + if (this.droppedChars > 0) { + const notice = `\r\n\x1b[2;37m[${this.droppedChars.toLocaleString('en-US')} characters of hidden terminal output omitted]\x1b[0m\r\n` + parts.push(notice) + budget = Math.max(0, budget - notice.length) + this.droppedChars = 0 + } + + while (budget > 0 && this.chunks.length > 0) { + const head = this.chunks[0]! + const available = head.length - this.headOffset + const take = Math.min(budget, available) + parts.push(head.slice(this.headOffset, this.headOffset + take)) + this.headOffset += take + this.pendingChars -= take + budget -= take + if (this.headOffset === head.length) { + this.chunks.shift() + this.headOffset = 0 + } + } + + const output = parts.join('') + if (!output) return + this.writing = true + this.write(output, () => { + this.writing = false + this.schedule() + }) + } +} diff --git a/src/renderer/src/components/ui/AppMenu.tsx b/src/renderer/src/components/ui/AppMenu.tsx index 69888ae..9bec12c 100644 --- a/src/renderer/src/components/ui/AppMenu.tsx +++ b/src/renderer/src/components/ui/AppMenu.tsx @@ -25,6 +25,7 @@ export type AppMenuAction = | { kind: 'updates' } | { kind: 'toggle-menulet' } | { kind: 'toggle-system-monitor' } + | { kind: 'quit-stop-brain' } interface AppMenuItem { id: string @@ -57,6 +58,7 @@ const MENU: AppMenuGroup[] = [ { id: 'archive', icon: 'archive', label: 'Archive', action: { kind: 'open-tab', tab: 'archive' } }, { id: 'updates', icon: 'refresh', label: 'Check for updates', action: { kind: 'updates' } }, { id: 'docs', icon: 'globe', label: 'Docs', action: { kind: 'docs' } }, + { id: 'quit-stop-brain', icon: 'square', label: 'Quit and stop Brain', action: { kind: 'quit-stop-brain' } }, ], }, ] @@ -73,6 +75,7 @@ export function AppMenu({ activeKind, footStatus, onPick }: AppMenuProps) { const ref = useRef(null) const isDark = useIsDark() const isWeb = getCrewCodeRuntime().kind === 'web' + const isBrain = getCrewCodeRuntime().kind === 'brain' useEffect(() => { if (!open) return @@ -127,7 +130,7 @@ export function AppMenu({ activeKind, footStatus, onPick }: AppMenuProps) { {MENU.map(g => (
{g.label}
- {g.items.filter(it => !isWeb || it.id !== 'updates').map(it => ( + {g.items.filter(it => (!isWeb || it.id !== 'updates') && (it.id !== 'quit-stop-brain' || isBrain)).map(it => ( +
+ ) + } + + return ( +
+ {prs.length > 1 && ( + )} - {prs.map(p => ( -
setExpanded(e => e === p.num ? null : p.num)}> -
- - {p.title} - #{p.num} -
-
- {p.head} - · - {p.author} · {p.updated} - {p.checks && p.checks.length > 0 && ( - - {p.checks.map((c, i) => )} - - )} - {p.reviews && p.reviews.length > 0 && ( - - {p.reviews.map((r, i) => {r.user.slice(0, 1).toUpperCase()})} - - )} -
-
- ))} -
- {expandedPr && ( -
- {expandedPr.desc &&
{expandedPr.desc}
} -
from{expandedPr.head}
-
into{expandedPr.base}
- {expandedPr.runs && expandedPr.runs.length > 0 && ( - <> -
checks
-
- {expandedPr.runs.map((r, i) => ( -
- - {r.name} - {r.dur} -
- ))} -
- - )} -
- - - -
+
+
{selectedPr.status}#{selectedPr.num}
+

{selectedPr.title}

+

{selectedPr.author || 'unknown'} wants to merge

+
{selectedPr.head}{selectedPr.base}
- )} -
- - {hasUnpushed ? `${branch} has unpushed commits` : `no PR yet for ${branch}`} - - +
+
Checks{failed ? `${failed} failing` : selectedPr.checks?.length ? `${passed}/${selectedPr.checks.length} passed` : 'None'}
+
Pending{pending}
+
Merge state{(selectedPr.mergeStateStatus ?? 'unknown').toLowerCase().replaceAll('_', ' ')}
+
+ +
{selectedPr.body || 'No description provided.'}
+ +
+ + +
+ +
+ {hasUnpushed ? `${branch} has unpushed commits` : `Working on ${branch}`} + +
- - ) + ) } /* ---------- History ---------- */ @@ -769,7 +742,8 @@ export function GitSidebar({ onStageFile, onUnstageFile, onStageAll, onUnstageAll, onDiscardFile, onOpenFileDiff, onCommit, onCreateWorktree, onSwitchWorktree, onMergeWorktree, onRemoveWorktree, onResolveConflict, onAbortMerge, onContinueMerge, - onCreatePR, onOpenPR, onMergePR, onApprovePR, + onCreatePR, onOpenPR, onMergePR, + onUpdatePRBranch, onClosePR, onReviewPR, onInitRepo, onPublish, onOpenTerminal, pluginGitLenses = [], @@ -782,6 +756,8 @@ export function GitSidebar({ const notRepo = state.isRepo === false const noCommits = (state.history || []).length === 0 const [publishOpen, setPublishOpen] = useState(false) + const [prCreateOpen, setPrCreateOpen] = useState(false) + const [reviewPr, setReviewPr] = useState(null) // Open which cards by default — conflicts always; changes when dirty; others closed. const [open, setOpen] = useState({ @@ -838,7 +814,7 @@ export function GitSidebar({ onPull={onPull} onFetch={handleFetch} onSync={onSync} - onCreatePR={onCreatePR} + onCreatePR={() => setPrCreateOpen(true)} onOpenTerminal={onOpenTerminal} onCheckoutBranch={onCheckoutBranch} onCreateBranch={onCreateBranch} @@ -991,10 +967,9 @@ export function GitSidebar({ prs={state.prs || []} branch={workspace.branch} hasUnpushed={state.ahead > 0} - onCreate={onCreatePR} + onCreate={() => setPrCreateOpen(true)} onOpen={onOpenPR} - onMerge={onMergePR} - onApprove={onApprovePR} + onReviewOpen={setReviewPr} /> @@ -1022,6 +997,26 @@ export function GitSidebar({ onPublish={async opts => (await onPublish?.(opts)) ?? false} onClose={() => setPublishOpen(false)} /> + branch.name.replace(/^origin\//, ''))} + defaultBase={state.defaultBase || state.comparisonRef || 'main'} + defaultTitle={state.history?.[0]?.msg || workspace.branch.replace(/[-_/]+/g, ' ')} + onCreate={async options => (await onCreatePR?.(options))?.ok ?? false} + onClose={() => setPrCreateOpen(false)} + /> + setReviewPr(null)} + /> ) } diff --git a/src/renderer/src/components/git/PullRequestModal.tsx b/src/renderer/src/components/git/PullRequestModal.tsx new file mode 100644 index 0000000..4319f8e --- /dev/null +++ b/src/renderer/src/components/git/PullRequestModal.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from 'react' +import type { GitHubPullRequestCreateContext, GitHubPullRequestCreateOptions } from '../../../../shared/github-types' +import { getCrewCodeClient } from '../../runtime/crewcode-client' +import { Icon } from '../ui/Icon' + +interface PullRequestModalProps { + open: boolean + repoPath: string + head: string + branches: string[] + defaultBase: string + defaultTitle: string + onCreate: (options: GitHubPullRequestCreateOptions) => Promise + onClose: () => void +} + +const STEPS = ['Branches', 'Details', 'Review'] as const + +export function PullRequestModal({ open, repoPath, head, branches, defaultBase, defaultTitle, onCreate, onClose }: PullRequestModalProps) { + const [step, setStep] = useState(0) + const [title, setTitle] = useState(defaultTitle) + const [body, setBody] = useState('') + const [base, setBase] = useState(defaultBase) + const [draft, setDraft] = useState(true) + const [creating, setCreating] = useState(false) + const [comparison, setComparison] = useState<{ loading: boolean; value?: GitHubPullRequestCreateContext; error?: string }>({ loading: false }) + const titleRef = useRef(null) + + useEffect(() => { + if (!open) return + setStep(0) + setTitle(defaultTitle) + setBody('') + setBase(defaultBase) + setDraft(true) + setCreating(false) + }, [open, defaultBase, defaultTitle]) + + useEffect(() => { + if (!open || !repoPath || !base.trim()) return + let cancelled = false + setComparison({ loading: true }) + const timer = setTimeout(() => { + getCrewCodeClient().githubPrCreateContext(repoPath, base.trim()).then(result => { + if (cancelled) return + if ('error' in result) setComparison({ loading: false, error: result.error }) + else setComparison({ loading: false, value: result }) + }).catch(error => { + if (!cancelled) setComparison({ loading: false, error: String(error) }) + }) + }, 220) + return () => { cancelled = true; clearTimeout(timer) } + }, [open, repoPath, base]) + + useEffect(() => { + if (!open) return + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !creating) onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, creating, onClose]) + + useEffect(() => { + if (open && step === 1) setTimeout(() => titleRef.current?.select(), 30) + }, [open, step]) + + if (!open) return null + + const submit = async () => { + if (!title.trim() || !base.trim() || creating) return + setCreating(true) + try { + const ok = await onCreate({ title: title.trim(), body: body.trim() || undefined, base: base.trim(), draft }) + if (ok) onClose() + } finally { + setCreating(false) + } + } + + const canContinueBranches = !!base.trim() && !comparison.loading && !!comparison.value + const canContinueDetails = !!title.trim() + + return ( +
{ if (!creating) onClose() }}> +
event.stopPropagation()}> +
+
+ 01 +

Create pull request

+
+ +
+ + + +
+ {step === 0 && ( +
+
+ Branch comparison +

Choose where this PR lands

+

CrewCode compares the current branch directly with the selected base.

+
+ +
+ + + + {branches.filter(branch => branch !== head).map(branch => +
+ +
+
{comparison.value?.ahead ?? '—'}ahead
+
{comparison.value?.behind ?? '—'}behind
+
{comparison.value?.changedFiles ?? '—'}files
+
+ {comparison.loading ? 'Checking' : comparison.value?.mergeStatus ?? 'Unknown'}merge state +
+
+ {comparison.error &&
{comparison.error}
} +
+ )} + + {step === 1 && ( +
+
+ Pull request details +

Explain the change

+

Give reviewers enough context to understand the intent and verify the result.

+
+ +