diff --git a/.gitignore b/.gitignore index e96fd39..016e628 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,11 @@ docs-website/.astro/ social-media-post.md Work/ promo-video +packaging/arch/pkg/ +packaging/arch/src/ +packaging/arch/*.deb +packaging/arch/*.pkg.tar.* +tester/ +test-workspace/ +.commandcode/ +examples/VPS_SETUP.md diff --git a/AGENTS.md b/AGENTS.md index 391f101..05f19a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,24 @@ The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer The design system lives in `.design/crewcode-design-system/`. The canonical CSS tokens are in `src/renderer/src/styles/colors_and_type.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 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. + +On phones, Code Editor keeps the code canvas primary and opens its file tree as a dismissible right overlay. Git Sidebar must remain the same stateful surface on desktop and mobile, becoming an off-canvas panel with backdrop/close controls instead of being hidden or squeezing chat/editor content. Changes by turn is a full-screen mobile review: its catalogue stacks above the diff when open, while a targeted changed-file route keeps the catalogue closed. Keep all JS/CSS decisions aligned at `≤768px`; see `docs/mobile-responsive-pages.md`. + +Git Workspace phone layout keeps the shared `useGitSidebar` state/actions, a compact two-column overview, changed files stacked above a bounded diff, and the remaining Git tools in a bounded scroll panel. Use the canonical `≤768px` breakpoint, ≥36px actionable controls, and 16px text inputs; do not restore fixed desktop-width columns or unbounded stacked panels. + +The final work-log changed-file chips and Turn Changes drawer must use the same turn-change aggregation. A chip targets its exact turn/file in the drawer with the agent-summary/list sidebar closed; do not route it through the ordinary editor file-open action or rebuild a second, lossy file list. + +Normalize multi-file provider output into one unified patch per file before rendering. Repeated edits to one file must merge their hunks under one canonical `diff --git` header so `PierreDiff` receives a single-file patch and does not drop to the raw fallback. + +Git Sidebar `gs-changes-list` rows open the active worktree's diff in Code Editor's existing `PierreDiff` surface. Git Workspace and Git Sidebar share `useGitSidebar` comparison state and use the workspace-scoped Settings default branch as a read-only base; never checkout that branch implicitly, diff the primary workspace when a worktree is active, or expose staging actions for committed comparison-only rows. + +Drawer thread rows may be dragged onto a Solo Chat `.chat-pane-row` or a terminal pane to join the window split group. Do not re-key the session or mount it into Workbench; same-tab splits use a viewport tab (`sessionOwnerTabId` + `pinnedSessionId`) and must not `ensureTab` the viewport id. Disable drag on phones. See `docs/workspace-session-split.md`. + **Hard rules:** - Background: `#0f120f` (dark), never pure black @@ -175,7 +193,17 @@ 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`, `Markdown Editor`, `Code Editor`, `Workbench Mode`, `Git Workspace/Sidebar`, [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`, `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. + +CrewCoder `crew-tasks` activity remains provider-owned and optional. Preserve the exact Task* tool name from ACP `_meta["crewcoder/tool"].name` even when ACP `name`/`kind` is generic (`think`/`other`). Treat `rawOutput.todos` as the authoritative session snapshot, and fold newer running Task* mutations over the current turn's last completed snapshot so live status is not masked. Merge a matching full `rawOutput.task` record without letting the lossy snapshot erase its stable id, session display number, description, owner/scope, metadata, dependency edges, or timestamps; keep provider-local generic todo ids out of that richer CrewCoder contract. Render `activeForm`, blocked pending state, completed state, owner, and display number consistently with the CrewCoder TUI. Every user message starts a fresh activity scope. Accept `TaskList` as native activity only with explicit `sessionOnly: true`; project-wide/default lists mix unrelated sessions and must be ignored. Use label-identified incremental TaskCreate/TaskUpdate/TaskDelete reconstruction as a compatibility fallback, never generic argument-shape guessing. Grok `todo_write` completion state comes from `result.TodosUpdated.state.todos` (the full session map); `TodosUpdated.todos` and merge arguments are a subset and must fold, not replace. CrewCode must not prompt providers to manufacture activity, fabricate tool events, or implicitly enable CrewCoder `crew-tasks`. CrewCoder-mode clarify and plan-approval cards are independent of `crew-tasks` and of tool-permission Allow/Deny; they stay visible when Todo activity is hidden. See `docs/agent-activity-overlay.md`. + +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`. + +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`. ## Plugin platform notes diff --git a/README.md b/README.md index 0ebba6c..cdddae6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ > Run, supervise, and review multiple AI coding agents across git worktrees without losing control of your repo. [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) +[![Website](https://img.shields.io/badge/Website-crewcode.logixhub.icu-0b7285.svg)](https://crewcode.logixhub.icu) +[![X](https://img.shields.io/badge/X-@OnPointTools-000000.svg)](https://x.com/OnPointTools) +[![YouTube](https://img.shields.io/badge/YouTube-@CjWisdom-FF0000.svg)](https://www.youtube.com/@CjWisdom) diff --git a/bin/crewcode-server.mjs b/bin/crewcode-server.mjs index ec0c48c..ed0d75b 100755 --- a/bin/crewcode-server.mjs +++ b/bin/crewcode-server.mjs @@ -6,18 +6,22 @@ import { fileURLToPath } from 'url' import { spawnSync } from 'child_process' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const entry = join(root, 'out', 'main', 'headless.js') +const args = process.argv.slice(2) +const requested = args[0] +const command = requested === 'hub' || requested === 'enroll' || requested === 'brain' ? requested : 'serve' +const entry = join(root, 'out', 'main', command === 'hub' ? 'hub.js' : command === 'serve' ? 'headless.js' : 'brain.js') if (!existsSync(entry)) { - console.error('CrewCode server build is missing. Run `npm run build` before starting from this checkout.') + console.error(`CrewCode ${command} build is missing. Run \`npm run build\` before starting from this checkout.`) process.exit(1) } // The Electron package sets ELECTRON_RUN_AS_NODE in some development shells. -// A headless server must always execute in ordinary Node.js. +// Headless services must always execute in ordinary Node.js. delete process.env.ELECTRON_RUN_AS_NODE const require = createRequire(import.meta.url) const module = require(entry) -module.runHeadless(process.argv.slice(2)).catch(error => { +const run = command === 'hub' ? module.runHub : command === 'serve' ? module.runHeadless : (argv => module.runBrainCommand(command, argv)) +run(command !== 'serve' || requested === 'serve' ? args.slice(1) : args).catch(error => { console.error(error?.message || String(error)) process.exitCode = 1 }) diff --git a/bin/yuheard.mjs b/bin/yuheard.mjs new file mode 100755 index 0000000..ba0276b --- /dev/null +++ b/bin/yuheard.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// yuheard — CLI for the YuHeard terminal agent-done channel. +// +// Subcommands: +// pane-id Print $YUHEARD_PANE_ID (or "(unset)"). +// socket Print the YuHeard socket path. +// running [message] Report 'running' for the calling pane. +// complete [message] Report 'complete' for the calling pane. +// --help Show this help. +// +// Env: +// YUHEARD_PANE_ID Set by CrewCode when it spawns a pty pane. +// The CLI sends this verbatim. +// YUHEARD_SOCKET Override the default socket path +// (~/.crewcode/yuheard.sock). +// +// Exit codes: +// 0 ok +// 1 socket error / no pane for cwd +// 2 bad arguments +// +// See docs/yuheard.md for the protocol and integration patterns. + +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +const args = process.argv.slice(2) +const sub = args[0] +const SOCKET = process.env.YUHEARD_SOCKET + ?? path.join(os.homedir(), '.crewcode', 'yuheard.sock') + +function send(line, { timeoutMs = 1000 } = {}) { + return new Promise((resolve, reject) => { + const sock = net.createConnection(SOCKET) + let buf = '' + let settled = false + const t = setTimeout(() => { + if (settled) return + settled = true + sock.destroy() + reject(new Error(`socket timeout after ${timeoutMs}ms`)) + }, timeoutMs) + sock.on('data', d => { buf += d.toString('utf8') }) + sock.on('end', () => { + if (settled) return + settled = true + clearTimeout(t) + resolve(buf.trim()) + }) + sock.on('error', e => { + if (settled) return + settled = true + clearTimeout(t) + reject(e) + }) + sock.write(line + '\n') + sock.end() + }) +} + +function reportLine(state, message) { + return JSON.stringify({ + pane_id: process.env.YUHEARD_PANE_ID ?? '', + state, + source: 'yuheard-cli', + message, + ts: Date.now(), + }) +} + +async function resolvePaneId(firstArg) { + if (process.env.YUHEARD_PANE_ID) return process.env.YUHEARD_PANE_ID + if (firstArg) { + try { + const lookedUp = await lookupPaneIdByCwd() + if (lookedUp) return lookedUp + } catch { /* fall through and treat firstArg as the pane id */ } + return firstArg + } + const lookedUp = await lookupPaneIdByCwd() + if (!lookedUp) throw new Error('lookup failed') + return lookedUp +} + +async function lookupPaneIdByCwd() { + const cwd = process.cwd() + const lookupLine = JSON.stringify({ method: 'pane-id-lookup', cwd }) + const reply = await send(lookupLine) + let parsed + try { parsed = JSON.parse(reply) } catch { throw new Error(`invalid server reply: ${reply}`) } + if (!parsed.ok) throw new Error(parsed.error ?? 'lookup failed') + return parsed.paneId +} + +function help() { + process.stdout.write(`Usage: yuheard [args] + + pane-id Print $YUHEARD_PANE_ID (or "(unset)"). + socket Print the YuHeard socket path. + running [message] Report running for the calling shell's pane. + complete [message] Report complete for the calling shell's pane. + --help Show this help. + +Env: + YUHEARD_PANE_ID Set by CrewCode when it spawns a pty pane. + YUHEARD_SOCKET Override default (~/.crewcode/yuheard.sock). + +Exit codes: + 0 ok 1 error 2 bad arguments +`) +} + +async function main() { + switch (sub) { + case 'pane-id': + process.stdout.write(`${process.env.YUHEARD_PANE_ID ?? '(unset)'}\n`) + return + case 'socket': + process.stdout.write(`${SOCKET}\n`) + return + case '--help': + case '-h': + case undefined: + help() + return + case 'running': + case 'complete': { + const extra = args.slice(1) + const paneId = await resolvePaneId(extra[0]) + const messageParts = extra[0] === paneId ? extra.slice(1) : extra + const message = messageParts.join(' ').trim() || undefined + const line = reportLine(sub, message).replace( + '"pane_id":""', + `"pane_id":"${paneId.replace(/"/g, '\\"')}"`, + ) + const reply = await send(line) + process.stdout.write(`${reply}\n`) + return + } + default: + process.stderr.write(`yuheard: unknown subcommand "${sub}"\n`) + help() + process.exitCode = 2 + } +} + +main().catch(err => { + process.stderr.write(`yuheard: ${err?.message ?? String(err)}\n`) + process.exit(1) +}) diff --git a/docs/README.md b/docs/README.md index 9f3411b..b33c88b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,9 @@ internal references). | [security-model.md](./security-model.md) | Authority-boundary audit: untrusted content -> agent -> MCP/plugin -> exec -> Git/SSH, per-hop gates, tests, and residual risk | | [execution-custody.md](./execution-custody.md) | Withdrawing authority after it was granted: invariant tripwire, custody journal, halt/contain/preserve/report, explicit reauthorization | | [remote-ssh-workspaces.md](./remote-ssh-workspaces.md) | Opening projects over SSH: setup, auth, host pinning, what works remotely | +| [web-remote-access.md](./web-remote-access.md) | Browser client, `crewcode serve`, self-hosted Hub relay, and `hub --local-brain` | | [canvas-mode.md](./canvas-mode.md) | Workbench: multiple chats and terminals side by side on one worktree _(nav label: "Workbench")_ | +| [workspace-session-split.md](./workspace-session-split.md) | Drag a drawer thread onto Solo Chat or a terminal pane to split that view | | [chat-archiving.md](./chat-archiving.md) | Right-click a chat to archive it; the Archive page, restoring, and retention flagging | ### Git @@ -77,6 +79,7 @@ internal references). | [agent-message-markdown.md](./agent-message-markdown.md) | Markdown/Shiki rendering rules for agent replies | | [tool-calling-breakdown.md](./tool-calling-breakdown.md) | Per-tool rendering spec for tool-call rows | | [notifications.md](./notifications.md) | Notification bar architecture and `useNotifications()` API reference | +| [yuheard.md](./yuheard.md) | Terminal-agent completion protocol, shell wrappers, Codex hook, alert settings, and troubleshooting | | [crew-orchestrator.md](./crew-orchestrator.md) | Crew orchestrator design spec: state machine, layering, modes | | [plugins-v0.md](./plugins-v0.md) | Plugin platform v0 implementation snapshot and pre-v1 gates | diff --git a/docs/agent-activity-overlay.md b/docs/agent-activity-overlay.md index 3e29353..088f3bf 100644 --- a/docs/agent-activity-overlay.md +++ b/docs/agent-activity-overlay.md @@ -1,17 +1,29 @@ # Agent activity overlay -The inline agent activity overlay displays provider-native todo, plan, task, and human-input progress. It is a passive renderer: CrewCode does not prompt agents to create task lists merely to activate the UI. +The inline agent activity overlay displays CrewCode-owned turn execution plus provider-native todo, plan, task, and human-input progress. It does not rely on prompt instructions or require an agent to remember to call a task tool. + +## CrewCode-owned turn lifecycle + +Every bridge-backed solo, lane, or supervisor dispatch creates one `activity` transcript record with a launch-scoped `activityRunId`. CrewCode advances it only from observed events: accepted startup is pending, `turn_start` is in progress, tool categories provide deterministic phases such as reading/editing/testing, and a normal `turn_end` completes it. Prompt rejection, bridge error/closure, custody halt, idle stop, or explicit stop becomes interrupted or cancelled. A terminal outcome cannot be overwritten by a later event. After an app restart, an activity record from the prior runtime projects as interrupted rather than silently resuming or becoming successful. Raw PTY agents do not receive this record because CrewCode cannot observe a structured terminal outcome. + +Provider-native todos enrich and replace the generic CrewCode row while its turn is active. Once CrewCode observes a terminal outcome, that outcome wins over stale native `pending` or `in_progress` items, preventing a finished agent from continuing to appear busy. CrewCode-owned activity is a lifecycle record, not a synthetic provider tool call, and is excluded from work-log tool rendering. ## Todo and plan detection -`todo-from-toolcall.ts` scans tool calls in the current turn and normalizes arrays named `todos`, `plan`, or `todoList` into a shared status model. This covers: +`todo-from-toolcall.ts` folds todo, plan, and task events from the whole chat into one overlay list. It is provider-agnostic: any tool payload that carries a `todos`, `plan`, or `todoList` array or keyed map lights up the same UI. That covers: - Claude/OpenCode todo payloads; - Codex `update_plan` notifications; - Pi `manage_todo_list` calls; -- compatible plugin/provider tool payloads. +- Grok `todo_write` calls, using `TodosUpdated.state.todos` as the full session store (the merge `todos` array is only the subset just written); +- CrewCoder `crew-tasks` snapshots and incremental Task* mutations; +- compatible plugin/provider tool payloads, including `subject` text and snapshots that arrive on `tool_update` metadata. -The scan stops at the latest user message so completed plans from an earlier turn do not reappear in a new turn. +Every user message starts a new activity scope. The prior turn's list clears immediately, including unfinished items. The new CrewCode lifecycle row appears without waiting for provider tools, and native evidence emitted after that user message may replace it while execution remains active. Within the current turn, an `in_progress` native item stays active only while the owning CrewCode turn is active. Sparse merge updates (`merge: true`, or entries with ids/status but no text) fold onto the current turn's last full list instead of replacing it, so unmentioned current-turn tasks are not dropped. + +CrewCoder's native todo layer is optional `crew-tasks` (`TaskCreate` / `TaskList` / `TaskGet` / `TaskUpdate` / `TaskDelete`). When `autoSyncTodos` is on, every Task* result returns a session `todos` snapshot on ACP `rawOutput`, which this overlay reads like Claude/Pi. The ACP bridge preserves the exact tool name from `_meta["crewcoder/tool"].name` instead of reducing it to the generic `think` kind. `TaskList` contributes activity only when its arguments explicitly set `sessionOnly: true`; a project-wide/default list is inspection data from multiple agent sessions and must never become one chat's overlay. If a turn only has incremental mutations (older CrewCoder, or `autoSyncTodos` off), the overlay reconstructs the list from label-identified `TaskCreate` / `TaskUpdate` / `TaskDelete` events. It does not infer CrewCoder ownership from generic argument shapes because that can capture another provider's plan tools. `crew-tasks` is **disabled by default** in CrewCoder (`crewcoder task on`); CrewCode-owned lifecycle activity still works when Task* is unavailable, without enabling it or fabricating a native plan. + +CrewCode keeps the lossy `rawOutput.todos` snapshot as the authoritative list/status view, but merges the matching full `rawOutput.task` record into its `TodoItem`. The retained contract matches current CrewCoder Agent and TUI records: stable `id`, session-local `displayNumber`, `subject`, `description`, `status`, `activeForm`, `owner`, `sessionId`, `projectPath`, `metadata`, `blocks`, `blockedBy`, `createdAt`, and `updatedAt`. Stable IDs and display numbers are intentionally separate. Snapshot reconciliation preserves those fields instead of erasing them. Rendering follows the TUI semantics: active work uses `activeForm`, blocked pending tasks use the blocked marker and dependency hint, completed tasks are struck through, owner/display-number hints are shown when available, and the list orders active before pending before completed work. Provider-local IDs from unrelated todo formats are not promoted to CrewCoder task identity. ## Claude task lifecycle @@ -42,6 +54,8 @@ This projection changes only CrewCode visualization. Tool availability and Claud Human-input request cards are independent of this preference. Approvals, questions, editor requests, and notifications must always render because the provider may be paused waiting for the response. Request rendering therefore takes precedence over both the Todo preference and a previously dismissed todo card. +CrewCoder-mode `crewcoder_clarify` / `crewcoder_propose_plan` is a session workflow gate, not a tool-permission pause. After those tools settle, the overlay shows a dedicated clarification or **Approve plan** card. Approve sends `/approve-plan` as a normal prompt (or follow-up if the turn is still running). It must never reuse Allow/Deny on a permission card — `/approve` is still only for pending tool-call grants. A later user message hides the card: `/approve-plan` or a short CrewCoder approval continues implementation, and a revision such as `yes, but also add logging` waits for the next `crewcoder_propose_plan`. Answering a clarification is not plan approval. The Todo preference must not hide this card. + ## Surfaces Todo activity is rendered in: @@ -56,3 +70,4 @@ Older Crew timeline rounds do not show an active overlay, preventing historical ## Task work-log rows The `task` row in `Messages.tsx` is distinct from the todo overlay. It renders a delegated tool/subagent call in the turn work log. The overlay renders the aggregate provider task or plan snapshot. +`TaskSummaryItem` remains backward-compatible with its generic `tool`/`text` form and can additionally carry a normalized full `task` record, which uses the same task row renderer as `TodoItem`. diff --git a/docs/agent-provider-context.md b/docs/agent-provider-context.md index 7af0428..96ce82e 100644 --- a/docs/agent-provider-context.md +++ b/docs/agent-provider-context.md @@ -24,20 +24,13 @@ CrewCoder restores sessions with ACP `session/load`. Its provider replay contain Claude normally avoids this fallback when a saved Claude session id exists, because its SDK `options.resume` path is preferred. -## Claude SDK project guidance without global skill bloat +## Claude SDK settings and native skills -Claude bridge turns call the Agent SDK with: +Claude bridge turns omit `skills` and `settingSources`. That is Claude CLI default behavior: user, project, and local settings load, and native skill discovery follows Claude — including `~/.claude`. Do not pass `skills: []` (hides the library) or `settingSources: []` (drops `CLAUDE.md` and other filesystem settings) unless CrewCode injects equivalent repo guidance itself. -```ts -settingSources: ['project'] -skills: [] -``` +CrewCode's own `.crewcode` skill flow stays separate: selected skill bodies are injected by the composer/session path only when the user applies them. -This preserves the active repository's project guidance (`CLAUDE.md` / supported project settings loaded by Claude Code) while preventing the user's global `~/.claude` settings and discovered skill library from being injected into every turn. - -`skills: []` intentionally disables passive SDK skill injection, including project skills. CrewCode's own skill flow is separate: selected `.crewcode` skill bodies are injected by the composer/session path only when the user applies them, so they remain available without a standing context cost. - -Do not switch Claude to `settingSources: []` unless you also add a CrewCode-owned replacement for repository guidance injection. Total SDK isolation removes global bloat, but also drops repo instructions that agents need for safe changes. +A large global skill library still costs context on every turn. Inspect `contextBreakdown` if occupancy spikes after this path is enabled. ## Claude context meter source of truth diff --git a/docs/code-editor.md b/docs/code-editor.md index bb93853..f0cf3c3 100644 --- a/docs/code-editor.md +++ b/docs/code-editor.md @@ -5,9 +5,11 @@ 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. +- 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. - Source documents, including Markdown opened in the code editor, soft-wrap long lines by default. Wrapping is visual only: it never inserts newlines or changes line-number semantics. +- At phone widths (`≤768px`), the editor canvas owns the page. Open tabs remain horizontally scrollable, path-bar actions stay reachable as touch targets, and status metadata scrolls instead of widening the viewport. The file tree starts closed and opens as a dismissible right-side overlay; selecting a file closes it. Problems and references use the editor width rather than squeezing the code canvas. - Direct `@codemirror/*` dependencies are declared in `package.json` as local `file:` dependencies. npm links them to the package sources under `packages/crew-codemirror`, so locally built changes are used by CrewCode at runtime. ## Local CodeMirror development diff --git a/docs/conversation-storage.md b/docs/conversation-storage.md index 04483c0..cde33f9 100644 --- a/docs/conversation-storage.md +++ b/docs/conversation-storage.md @@ -32,6 +32,12 @@ On first access, CrewCode copies legacy entries into sharded files and writes `c If a sharded file is missing or unreadable, CrewCode can lazily recover that session from the legacy monolith. Explicitly cleared sessions are recorded in `conversations/.agent-conversations-cleared.json` so legacy fallback does not resurrect deleted conversations. +### 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. + +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. + ## Session ids and the context-meter baseline `src/main/agents/sessionStore.ts` persists per-session bridge state under `userData/agent-sessions.json`, keyed by the `tabId:agentId` composite the renderer uses for bridge registration: diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index 8061fed..fbe15fc 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -2,8 +2,9 @@ CrewCoder is a first-class CrewCode chat and Crew provider implemented by `src/main/agents/crewcoder-bridge.ts`. CrewCode is the ACP **client**: it spawns -`crewcoder acp --approval review` and translates newline-delimited JSON-RPC 2.0 -onto the shared `AgentBridge` event stream. CrewCoder remains the ACP agent. +`crewcoder acp --approval review`, optionally adding a selected CrewCoder +`--mode`, and translates newline-delimited JSON-RPC 2.0 onto the shared +`AgentBridge` event stream. CrewCoder remains the ACP agent. CrewCoder-specific usage metadata, permission semantics, reasoning, and tool-call ordering must not change Hermes behavior. @@ -23,6 +24,36 @@ binary path. The provider picker shows one **CrewCoder** provider; its model picker spans CrewCoder's configured backends using `provider:model` ids such as `codex:gpt-5.6-sol` and `opencode:claude-sonnet-4-6`. +When CrewCoder is installed and selected as the active provider, the desktop +model-row reveal also shows a **crew** mode picker. It offers CrewCoder's +`general`, `crewcoder`, `plugin`, and `extension` profiles plus **Configured +default**. The selection is session-scoped and survives app restarts and chat +duplication. Configured default omits `--mode`, preserving the user's CrewCoder +configuration and compatibility with older installs. Choosing a concrete +profile restarts only the CrewCoder ACP process and resumes the same native +session under the selected profile. The picker closes and stays disabled while +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. + +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. + +The `crewcoder` profile adds a runtime inspect → clarify → plan → approve +sequence inside CrewCoder. CrewCode does not enforce that gate; it projects +`crewcoder_clarify` and `crewcoder_propose_plan` into the agent activity overlay +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 +plan is a normal composer message; CrewCoder treats that as a new +`awaiting_plan` cycle rather than approval. + ## ACP lifecycle The bridge performs this handshake before reporting ready: @@ -77,7 +108,9 @@ failure. CrewCoder ACP respects CrewCoder's persisted `autoCompact` setting. CrewCode does not force compaction or retry context-window failures. Automatic and provider-neutral safety compaction are reported live through `_crewcoder/compaction_update`, allowing CrewCode to show the compaction meter -while CrewCoder summarizes in the background. When automatic compaction is off, the user explicitly +while CrewCoder summarizes in the background. If the ACP child exits, CrewCode removes that dead +bridge registration; the next composer submission uses normal missing-bridge recovery rather than +attempting to write to closed stdin and surfacing `crewcoder acp: process not writable`. When automatic compaction is off, the user explicitly runs `/compact` before continuing; this policy does not affect Pi or other providers. A prompt has a ten-minute **inactivity** watchdog rather than a wall-clock turn @@ -92,6 +125,41 @@ Usage prefers `_meta["crewcoder/usage"]`: `lastInputTokens` is the live `contextTokens` value and `contextWindow` is the context limit. Top-level usage is only the compatibility fallback. +ACP `tool_call` updates carry a category `kind` (`read`, `edit`, `think`, …), a +human `title`, and authoritative CrewCoder tool identity in +`_meta["crewcoder/tool"].name`. The bridge records that metadata as `toolName` +even when ACP also fills a generic `name`/`kind` such as `think`; +bare identifier titles remain a compatibility fallback for older CrewCoder ACP +streams. + +## Todo activity + +CrewCode owns a generic turn-lifecycle activity row, so its overlay does not +depend on CrewCoder calling task tools. CrewCoder has no Claude-style +`TodoWrite` snapshot tool. The matching native layer is `crew-tasks`, which is +disabled until `crewcoder task on` (or `/task on` in the CrewCoder TUI). When +enabled, every Task* result includes a session `todos` snapshot on ACP `rawOutput` +(`content` / `status` / `activeForm`) so the overlay lights up the same way as +Claude and Pi. The bridge preserves the exact Task* name from CrewCoder's ACP +metadata. Older incremental-only turns still reconstruct from `TaskCreate` / +`TaskUpdate` / `TaskDelete` arguments and result metadata. A new user message +clears the prior native list immediately; Task* evidence from the new turn +temporarily enriches the CrewCode-owned lifecycle while execution is active. +CrewCode does not enable `crew-tasks` on ACP spawn and never fabricates Task* +results. A `TaskList` result is eligible only when the call +explicitly used `sessionOnly: true`; project-wide lists can contain unfinished +tasks owned by unrelated CrewCoder sessions and are not chat activity. + +The renderer preserves the complete `rawOutput.task` record alongside the +authoritative `rawOutput.todos` snapshot instead of reducing CrewCoder tasks to +three display fields. This includes stable task id, session-local display +number, subject/description, owner/session/project identity, metadata, +dependency edges, and creation/update timestamps. Snapshot updates change the +list and status without discarding those details. CrewCode's `TodoItem` and the +optional task payload on `TaskSummaryItem` render the same active-form, +blocked-pending, completed, owner, and display-number semantics as the +CrewCoder TUI, including active → pending → completed ordering. + ## Mode and permission enforcement CrewCoder always starts with `--approval review`; CrewCode applies the current @@ -110,7 +178,12 @@ tool; those remembered decisions would bypass a later live composer-mode change. Once-only choices preserve the invariant that current mode is always authoritative. CrewCode `ModeLevel` (`ask`, `plan`, `build`, `full`) must never be passed to -CrewCoder's unrelated `--mode` option (`general`, `plugin`, `extension`). +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. ## Filesystem and SSH behavior @@ -151,10 +224,12 @@ registry entries yet; `session/new` and `session/load` send an empty MCP list. ```bash rtk vitest run src/main/agents/crewcoder-bridge.test.ts rtk vitest run src/main/agents/compaction-meter.test.ts +rtk vitest run src/renderer/src/components/thread/crewcoder-plan-gate.test.ts rtk npm run typecheck ``` Manual checks should cover model discovery, streamed text/reasoning, one row per tool id, Build permission prompts held longer than ten minutes, Ask rejection, -Full auto-accept, inactivity cancellation, native resume, usage, and an SSH text -read/write round trip. +Full auto-accept, inactivity cancellation, native resume, usage, an SSH text +read/write round trip, and CrewCoder-mode clarify then propose-plan overlay +approval via `/approve-plan`. diff --git a/docs/current-state.md b/docs/current-state.md index 5a4b8d4..1e14c63 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,17 +4,21 @@ Real agent integration is wired through normalized bridges (pi, OpenCode, Claude Workspaces, worktrees, git operations, terminals, settings, and crew sessions are all real and persisted (workspaces + tabs to disk, messages to localStorage). +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 -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. 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. See `docs/crewcoder-provider.md`. +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. + +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`. ## ACP Grok Build -Grok Build is a third ACP provider implemented separately in `grok-bridge.ts`; CrewCode is the client and spawns `grok agent stdio`. Keep Hermes and CrewCoder untouched. Its defining constraint is that **Grok resolves its own permission mode from `~/.grok/config.toml`, project `.grok/config.toml`, and Claude-compatible `.claude/settings.json`** — a user with `permission_mode = "always-approve"` gets a session that never asks the client and silently runs tools, voiding Ask/Plan/Build. `--permission-mode` is therefore always passed, is a **top-level** flag that must precede the `agent` subcommand (`agent stdio` accepts none of these flags), and maps ask/plan/read-only to `dontAsk`, build to `default`, full to `bypassPermissions`. Never let Grok's config decide the mode, and never send `_meta.full_access` on `session/new` — that field only escalates. The mode is a floor, not the enforcement: `dontAsk` was measured to still *prompt* for a client-side write rather than auto-deny, so the real gates are CrewCode's own permission-request refusal and its `fs/write_text_file` refusal (Grok delegates writes to the client, making that call the last gate before disk), plus a tool-announcement gate that trusts `_meta['x.ai/tool'].read_only` and falls back to a name check so unknown mutating tools fail closed. Grok's native plan mode must **not** be used for enforcement — always-approve stays armed underneath it and only *edits* are blocked, so bash still auto-runs; this mirrors the existing Claude rule. Permission choices are once-only: `allow-edits-session` is filtered out by ACP `kind` (never by vendor id spelling) because a session-scoped grant outlives the turn, leaving turn grants to `turn-permission-grants.ts`. Token usage arrives **only** on the vendor `_x.ai/session_notification` channel as `response_completed`, so a standard-only ACP client renders a dead context meter; per-turn `_meta.inputTokens` is live context occupancy while `_meta.usage.*` is cumulative across model calls and must never be read as occupancy. Reasoning effort is Grok's `low|medium|high` with no off: the picker shows exactly those three using Grok's own wording, and the bridge additionally clamps defensively because an unknown value is rejected for the whole process; effort is spawn-time, so changing it restarts the bridge. Grok numbers its server→client requests from `0`, so id presence must be tested with `typeof id === 'number'`, never truthiness. Model discovery reads the catalog off the `initialize` response and creates no session. Follow-ups queue locally in the bridge (Claude-style) rather than through Grok's own upstream prompt queue, because upstream queueing gives up composer-visible pending items and cancellation while behaving identically otherwise; `drainFollowUps()` must run **after** `endTurn()` since the drain is gated on `currentTurnId` being cleared, and abort/stop clear the queue. Terminal capability, slash commands, and `--deny` rules are deliberately not wired. See `docs/grok-provider.md`. +Grok Build is a third ACP provider implemented separately in `grok-bridge.ts`; CrewCode is the client and spawns `grok agent stdio`. Keep Hermes and CrewCoder untouched. Its defining constraint is that **Grok resolves its own permission mode from `~/.grok/config.toml`, project `.grok/config.toml`, and Claude-compatible `.claude/settings.json`** — a user with `permission_mode = "always-approve"` gets a session that never asks the client and silently runs tools, voiding Ask/Plan/Build. `--permission-mode` is therefore always passed, is a **top-level** flag that must precede the `agent` subcommand (`agent stdio` accepts none of these flags), and maps ask/plan/read-only to `dontAsk`, build to `default`, full to `bypassPermissions`. Never let Grok's config decide the mode, and never send `_meta.full_access` on `session/new` — that field only escalates. The mode is a floor, not the enforcement: `dontAsk` was measured to still *prompt* for a client-side write rather than auto-deny, so the real gates are CrewCode's own permission-request refusal and its `fs/write_text_file` refusal (Grok delegates writes to the client, making that call the last gate before disk), plus a tool-announcement gate that trusts `_meta['x.ai/tool'].read_only` and falls back to a name check so unknown mutating tools fail closed. Grok's native plan mode must **not** be used for enforcement — always-approve stays armed underneath it and only *edits* are blocked, so bash still auto-runs; this mirrors the existing Claude rule. Permission choices are once-only: `allow-edits-session` is filtered out by ACP `kind` (never by vendor id spelling) because a session-scoped grant outlives the turn, leaving turn grants to `turn-permission-grants.ts`. Token usage arrives **only** on the vendor `_x.ai/session_notification` channel as `response_completed`, so a standard-only ACP client renders a dead context meter; per-turn `_meta.inputTokens` is live context occupancy while `_meta.usage.*` is cumulative across model calls and must never be read as occupancy. Reasoning effort is Grok's `low|medium|high` with no off: the picker shows exactly those three using Grok's own wording, and the bridge additionally clamps defensively because an unknown value is rejected for the whole process; effort is spawn-time, so changing it restarts the bridge. Grok numbers its server→client requests from `0`, so id presence must be tested with `typeof id === 'number'`, never truthiness. Model discovery reads the catalog off the `initialize` response and creates no session. Grok todo activity uses the settled `todo_write` `result.TodosUpdated.state.todos` map as the full session store; merge arguments and `TodosUpdated.todos` are a subset and must fold, not replace. Follow-ups queue locally in the bridge (Claude-style) rather than through Grok's own upstream prompt queue, because upstream queueing gives up composer-visible pending items and cancellation while behaving identically otherwise; `drainFollowUps()` must run **after** `endTurn()` since the drain is gated on `currentTurnId` being cleared, and abort/stop clear the queue. `session/prompt` returns as soon as the turn opens; the drawer Running shortcut follows `runningByScope`, not the later `turn_end`. Terminal capability, slash commands, and `--deny` rules are deliberately not wired. See `docs/grok-provider.md`. ## Sidebar Folder Creation -Workspace folder actions are state-aware: a project's context menu always offers **Create folder**, while **Move to folder** appears only after at least one destination folder exists. The workspace drawer keeps all workspace groups above global Working/Completed/Terminals activity, then renders one **Threads** section scoped to the selected workspace; never reintroduce per-workspace inline session expansion. Every section has a semantic icon. Local workspace rows abbreviate the exact OS home-directory prefix with `~`, retain the absolute path as hover text, and leave SSH roots unchanged. Completed activity is a transient shortcut: hide it one hour after completion without deleting or archiving the underlying session or transcript. Session pins persist on `Session` and sort stably before unpinned peers inside the existing normal or delegated group; pinning must not erase delegated provenance or make duplicates inherit a pin. +Workspace folder actions are state-aware: a project's context menu always offers **Create folder**, while **Move to folder** appears only after at least one destination folder exists. The workspace drawer keeps all workspace groups above global Working/Completed/Terminals activity, then renders one **Threads** section scoped to the selected workspace; never reintroduce per-workspace inline session expansion. Every section has a semantic icon. Local workspace rows abbreviate the exact OS home-directory prefix with `~`, retain the absolute path as hover text, and leave SSH roots unchanged. The Running shortcut is live bridge activity keyed by conversation scope in `bridge-activity-store` (`runningByScope`); the drawer subscribes to that map itself so ACP providers that acknowledge `prompt()` before `turn_end` (Grok, CrewCoder, Hermes) appear while the turn is in flight, not only after it completes. Completed activity is a transient shortcut: hide it one hour after completion without deleting or archiving the underlying session or transcript. Session pins persist on `Session` and sort stably before unpinned peers inside the existing normal or delegated group; pinning must not erase delegated provenance or make duplicates inherit a pin. ## Crew Supervisor @@ -38,7 +42,7 @@ Chat archiving (`Session.archived`) is non-destructive: archiving releases the s ## Hide work Logs -Settings include `hideVerboseAgentLogs`, which filters thinking/toolcall/worklog rows at the shared `Messages` renderer. Keep final agent replies, user messages, and important system/status meters visible; do not delete verbose messages from storage just because they are hidden in the UI. +Settings include `hideVerboseAgentLogs`, which filters thinking/toolcall/worklog rows at the shared `Messages` renderer. Keep final agent replies, user messages, and important system/status meters visible; do not delete verbose messages from storage just because they are hidden in the UI. Work logs and thinking traces use the utilities-only Tailwind renderer integration while retaining their real message/tool mapping, expansion behavior, diagnostics, diffs, and file actions. During execution, tool runs remain visible in stream order; once a later agent response exists, all earlier tool calls in that turn consolidate into one initially expanded work log directly before the latest response. That final log ends with a responsive strip of every unique file changed during the turn, using aggregate add/remove counts across repeated edits. Clicking a changed-file chip opens that exact turn/file in Turn Changes with the agent-summary/list sidebar closed; row-level filenames still open the editor. The drawer and chips share one aggregation path that retains multi-file provider patches, nested edit payloads, and repeated changes to the same file. Multi-file unified patches are split by file, and repeated same-file provider hunks are merged under one canonical header so each drawer selection remains compatible with PierreDiff instead of falling back to raw lines. Keep layouts bounded on mobile and do not replace real data with template/demo content. See `docs/tailwind-renderer.md`. ## Realtime Voice Orb @@ -52,13 +56,17 @@ Voice orb start/end shortcuts are component-local keybindings exposed in Setting 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`. +## YuHeard Terminal Alerts + +YuHeard is the terminal-only agent-done channel. It listens on a per-user Unix socket at `~/.crewcode/yuheard.sock` (overridable via `YUHEARD_SOCKET`) and accepts newline-delimited JSON reports from any client — a Claude Code hook, the `bin/yuheard` CLI, or an auto-wrap shell shim that CrewCode installs per pane. Chat bridge turn-end must not call YuHeard; Solo/Crew chat tabs and their sidecar PTYs are ineligible (`tabKindAllowsYuHeard`). Those chats keep `notificationSound` and the existing in-app toast only. For terminal panes the renderer plays a `knock` sound, shows an in-app toast, and (when unfocused) an OS notification titled "Terminal agent finished". CrewCode does not infer turn-complete from a still-running TUI: BEL / OSC 9 / OSC 777 complete immediately on eligible panes; idle-after-output (~2.8s after a large burst) is armed for agent-tagged panes, after a socket `running` report, or when the user submits a line whose first command is a known agent CLI. **Codex TUI never goes idle** and its built-in TUI notify defaults to unfocused-only, so spawn/wrap injects `-c notify=[python-or-node, per-pane hook]` plus `tui.notifications=true`, `tui.notification_method="bel"`, and `tui.notification_condition="always"` instead of editing `~/.codex/config.toml`. Fish panes redefine agent functions after rc so a `function codex` cannot skip the shim; bash drops matching aliases/functions via `PROMPT_COMMAND`. XTermPane reattach sends the same YuHeard spawn flags as the owner `ptyCreate`. See `docs/yuheard.md`. + ## Agent Messages -Completed agent-message Markdown fenced code uses the shared safe Shiki `CodeBlock`; streaming text and inline code remain lightweight. Shiki and semantic Markdown accents (headings, list markers, emphasis, links, inline code) must use `--syntax-*` CSS-variable references derived from canonical theme tokens in `colors_and_type.css`, never a fixed bundled palette, so live theme changes recolor existing messages without re-tokenization. Keep body/list text readable, the 80,000-character fallback, and React-node rendering (no `innerHTML`). See `docs/agent-message-markdown.md`. +Streaming answer presentation may use Tailwind utilities, but must remain one lightweight text node while live; do not reintroduce per-word animation or template citations/follow-ups that are not backed by real message data. Completed agent-message Markdown fenced code uses the shared safe Shiki `CodeBlock`; streaming text and inline code remain lightweight. Shiki and semantic Markdown accents (headings, list markers, emphasis, links, inline code) must use `--syntax-*` CSS-variable references derived from canonical theme tokens in `colors_and_type.css`, never a fixed bundled palette, so live theme changes recolor existing messages without re-tokenization. Keep body/list text readable, the 80,000-character fallback, and React-node rendering (no `innerHTML`). See `docs/agent-message-markdown.md`. ## Agent Task Activity -Agent task activity is provider-native and passive: CrewCode visualizes task/plan events but does not force agents to create them. Claude SDK `task_started`/`task_updated` system messages are projected by `claude-bridge.ts` into one synthetic `claude_tasks` toolcall per turn, excluding `skip_transcript` housekeeping and settling before `turn_end`; if the SDK omits a terminal update, successful turns finalize running tasks as completed and abort/error cancels them. Reset state between turns, and treat settled result snapshots as authoritative over streaming args. `latestTodoActivity()` must remain current-turn scoped and power normal chat plus Crew lane/timeline/supervisor overlays. The global **Settings → General → Todo activity** preference hides only aggregate todo UI; approvals/questions must render regardless of that preference or todo-card dismissal because they can pause the provider. Keep delegated `task` work-log rows distinct from aggregate todo activity. See `docs/agent-activity-overlay.md`. +Agent task activity is provider-native and passive: CrewCode visualizes task/plan events but does not force agents to create them. `latestTodoActivity()` is one fold for every provider: it reads `todos`/`plan`/`todoList` arrays or keyed maps from args, result, and `tool_update` metadata, folds sparse merges onto the current turn's last full list, and starts a fresh scope on every user message. Claude SDK `task_started`/`task_updated` system messages are projected by `claude-bridge.ts` into one synthetic `claude_tasks` toolcall per turn, excluding `skip_transcript` housekeeping and settling before `turn_end`; if the SDK omits a terminal update, successful turns finalize running tasks as completed and abort/error cancels them. Treat settled result snapshots as authoritative over streaming args. CrewCoder task tools emit a session `todos` snapshot on ACP `rawOutput` when `autoSyncTodos` is on; the fold prefers that snapshot and otherwise reconstructs optional label-identified `crew-tasks` `TaskCreate`/`TaskUpdate`/`TaskDelete` mutations. Merge matching full `rawOutput.task` records into the snapshot and preserve stable id, session display number, subject/description, owner/scope, metadata, dependency edges, and timestamps; render active forms, blocked pending tasks, owners, and completion like the CrewCoder TUI. Only an explicit `TaskList({ sessionOnly: true })` may replace the session snapshot; project-wide/default lists are excluded. Grok's full session store is `result.TodosUpdated.state.todos`; the merge `todos` array is a subset and must not drop unmentioned tasks. Those task rows stay off the work log. `crew-tasks` stays opt-in on the CrewCoder side (`crewcoder task on`). The activity selector powers normal chat plus Crew lane/timeline/supervisor overlays. The global **Settings → General → Todo activity** preference hides only aggregate todo UI; approvals/questions must render regardless of that preference or todo-card dismissal because they can pause the provider. CrewCoder-mode clarify/plan-approval cards are the same: they stay visible with Todo activity off, send `/approve-plan` instead of a permission response, and clear after the next local user message. Keep delegated `task` work-log rows distinct from aggregate todo activity; a `TaskSummaryItem` may carry the same normalized task record without replacing its generic delegated-tool summary form. See `docs/agent-activity-overlay.md`. ## Cusromization Panel @@ -76,7 +84,7 @@ Reasoning effort choices are provider-native: Claude exposes `off/low/medium/hig ## Claude SDK Global skills isolation -Claude SDK turns must preserve project guidance without loading global skill bloat: use `settingSources: ['project']` with `skills: []`. Do not switch to full SDK isolation (`settingSources: []`) unless CrewCode injects equivalent repo guidance itself. See `docs/agent-provider-context.md`. +Claude SDK turns omit `skills` and `settingSources` so Claude CLI defaults apply (user/project/local settings and native skill discovery, including `~/.claude`). Do not pass `skills: []` — that hides Claude's skill library from the model. Do not switch to full SDK isolation (`settingSources: []`) unless CrewCode injects equivalent repo guidance itself. CrewCode `.crewcode` skills remain a separate on-demand injection. See `docs/agent-provider-context.md`. ## Agent Conversation @@ -88,13 +96,24 @@ Provider switching mid-chat is context handoff, not true provider state migratio Manual `/compact` uses the same disposable-summary pattern: generate an AI summary from a bounded transcript in a temporary session, show the summary in chat, replace CrewCode's local replay history with that summary, clear the provider-native resume id, and start fresh on the next prompt. +Manual context handoff is available from the Solo Chat header and `/handoff`. Its card targets either a new chat (with provider/model/effort selection) or any other live chat shown in the current workspace's Sessions list, across that workspace's chat tabs. Used destinations retain their existing provider/model/effort, owning tab, transcript, and selected worktree. Starting either destination type closes the card immediately, reveals the destination through normal session navigation, and leaves progress or failure visible on its handoff meter; a disposable destination-provider session summarizes the source, the packet is appended to destination local replay history, and the destination native resume id is cleared before continuation. Remote browser handoff now follows the same flow through Brain-local `web:` conversation shards: source history stays on the Brain, the authenticated owner must own the destination bridge, a running destination refuses mutation, and the updated destination history is replayed exactly once on the next native-provider prompt. See `docs/provider-context-handoff.md`. + ## Chat +Drawer threads can be dragged from `ws-session-list` onto a Solo Chat +`.chat-pane-row` or a terminal pane to join the existing window split group. +Same-tab threads cannot share one active session, so a drop that would steal the +visible pane mints a session viewport tab (`sessionOwnerTabId` + `pinnedSessionId`) +instead of re-keying the session or mounting the thread into Workbench. Viewport +tabs must not run `ensureTab` on their own id. Phone layouts do not drag. A split +tab's close control removes that tab only; it must not dissolve the whole group. +See `docs/workspace-session-split.md`. + Visible chat transcripts persist in two layers (`src/renderer/src/stores/chat-messages-store.ts` + `src/main/transcript-store.ts`). **L2 — disk** is authoritative and unbounded: one file per scope under `userData/transcripts/transcript..json` holding the full rich `Message[]`, written back on the same settle/debounce cadence and via a synchronous IPC batch (`transcripts:saveSyncBatch`) on window teardown. **L1 — `crewcode:messagesByTab` localStorage** is a bounded, synchronous cache for instant paint on launch; it caps each scope's tail and, on `QuotaExceededError`, evicts the least-recently-touched scopes so the newest conversation always wins. On launch the store hydrates from L2, backfilling anything L1 evicted. Do NOT treat localStorage as the source of truth or let `persist()` swallow quota errors silently — that was the original "recent messages vanish on restart" data-loss bug. Growing turns stay memory-only: do not serialize L1 or structured-clone L2 for live thinking/agent/tool rows, because those synchronous renderer costs caused the workspace-wide hitch on structural stream events. A settled scope schedules both layers at bounded idle; pagehide/beforeunload/hidden visibility synchronously flushes even live scopes, which is the durability guarantee. User messages are settled and therefore persist before normal agent work, while a crash may lose only the partial in-progress response. Main's `transcripts:save` is a last-wins async queue (`fsp.writeFile`), never a sync write on the IPC handler; the teardown sync batch drops queued payloads for scopes it writes so a stale async write can't clobber it. Transcript mtimes are cached in main after the launch scan and updated on writes/removes—never re-read and JSON-parse every shard per `transcripts:mtimes` request (that blocked Browser main for 1.2–1.3s on Mission Control tool-state refreshes). Explicit session deletion must also call `transcripts:remove`; the reconciliation prune must NOT delete disk files. `persist()` is a **hot path — it runs on every structural message, i.e. every tool call**. Per-scope serialization is cached in a `WeakMap` keyed by the scope's `Message[]` identity (arrays are replaced immutably, so reference equality proves the tail is unchanged): a write touches one scope but the payload needs all of them, and re-stringifying every scope each time cost 20-28ms per tool call. Do not drop that cache or key it by anything weaker than array identity. It must serialize each changed scope exactly once and assemble the payload by string concat under explicit budgets (`MAX_PERSISTED_SCOPES`, `L1_BYTE_BUDGET`, `PER_SCOPE_BYTE_CAP`, and `MAX_PERSISTED_MESSAGES_PER_SCOPE = 60` — sized just above the DOM pager's `PAGE_SIZE = 50`). Never re-stringify the whole map once per evicted scope: quota is the *steady state*, so the old snapshot-then-retry loop did O(scopes) multi-megabyte serializations and blocked the renderer for ~2s on every tool call. Quota retries must only re-join already-serialized strings. `scopeLastTouched` must stay a **monotonic counter, not `Date.now()`** — same-millisecond ties let eviction shed the newest conversation instead of the coldest. Quota/eviction behavior is covered in `chat-messages-store.test.ts`; keep it covered. -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` (the drawer and Mission Control fan out over every session via `isBridgeRunning`), but 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. +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. 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. @@ -108,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, and plugin editor actions. 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. 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 @@ -116,4 +135,19 @@ Workbench/Canvas Mode is a fresh workspace surface (`kind: 'canvas'`) for workbe ## Git Workspace/Sidebar -The standalone Git Workspace tab (`kind: 'git'`) is a full-page surface backed by the same `useGitSidebar` state/actions as `GitSidebar`. Keep Git Sidebar and Git Workspace behavior consistent; page-specific changes should live in `GitPage`/CSS unless the underlying git behavior truly changes. Push authentication uses a one-shot `GitAuthModal` plus temporary askpass helper; do not persist Git passwords/tokens unless a dedicated secure keychain flow is added. See `docs/git-workspace.md` and `docs/git-authentication.md`. +The standalone Git Workspace tab (`kind: 'git'`) is a full-page surface backed by the same `useGitSidebar` state/actions as `GitSidebar`. Keep Git Sidebar and Git Workspace behavior consistent; both compare the active worktree to Settings' workspace-scoped default branch when configured, include committed and untracked differences, and expose stage controls only for actual working-tree changes. Page-specific changes should live in `GitPage`/CSS unless the underlying git behavior truly changes. Push authentication uses a one-shot `GitAuthModal` plus temporary askpass helper; do not persist Git passwords/tokens unless a dedicated secure keychain flow is added. See `docs/git-workspace.md` and `docs/git-authentication.md`. + +## Mobile-responsive Pages + +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. +- **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 ` +
+
+ {ws.workspaces.map(workspace => ( + + ))} +
+
+
Quick Actions
+ +
+ + ) + + const GitSheetContent = () => ( +
+ {hasWs && activeWorkspace ? ( +
+
+
{activeWorkspace.name}
+
{effectivePath}
+
Branch: {effectiveBranch}
+
+
+ + + + + +
+
+ ) : ( +
+ No workspace selected +
+ )} +
+ ) + + const TerminalSheetContent = () => ( +
+ {hasWs ? ( + <> + pty.addShell(activeWs, activeTabId, effectivePath, effectiveShell(settings))} + onAddAgent={(agentId) => { + const a = agents.find(x => x.id === agentId) + return a ? pty.addAgent(activeWs, activeTabId, a.id, a.name, effectivePath, a.path) : undefined + }} + onAddSsh={(target) => pty.addSsh(activeWs, activeTabId, target, effectivePath)} + sshTargets={sshTargets} + layout={pty.getTabLayout(activeTabId)} + onLayoutChange={(layout) => pty.setTabLayout(activeTabId, layout)} + onOpenUrl={openBrowserUrl} + pluginTerminalWatchers={pluginTerminalWatchers} + onPluginTerminalWatcher={(target, paneId) => runPluginActionTarget(target, { source: 'terminal-watcher', terminalPaneId: paneId })} + /> + + ) : ( +
+ No workspace selected +
+ )} +
+ ) + + const MoreSheetContent = () => ( +
+
+

Settings

+ + + + + +
+
+

Actions

+ + + +
+
+ ) + return ( -
- {loadingScreenMounted && } + + }, + git: { + open: mobile.sheets.git?.open ?? false, + title: 'Git', + content: + }, + terminal: { + open: mobile.sheets.terminal?.open ?? false, + title: 'Terminal', + content: + }, + more: { + open: mobile.sheets.more?.open ?? false, + title: 'More', + content: + }, + 'mission-activity': { + open: mobile.sheets['mission-activity']?.open ?? false, + title: 'Activity', + content: + }, + }} + onSheetToggle={mobile.onSheetToggle} + > +
+ {loadingScreenMounted && } + + {!settings.onboardingCompleted && !showLoadingScreen && ( + setSetting('defaultAgent', id)} + hasProjects={ws.workspaces.length > 0} + onAddProject={() => setAddOpen(true)} + onFinish={() => setSetting('onboardingCompleted', true)} + /> + )} - {!settings.onboardingCompleted && !showLoadingScreen && ( - setSetting('defaultAgent', id)} - hasProjects={ws.workspaces.length > 0} - onAddProject={() => setAddOpen(true)} - onFinish={() => setSetting('onboardingCompleted', true)} +
+ runPluginActionTarget(item.target, { source: 'plugin-menu' })} + onNewTabMenuOpenChange={setWindowTabsMenuOpen} + /> +
+ + + resolveGitAuth(null)} + /> + resolveSigningPassphrase(null)} + /> + - )} - - runPluginActionTarget(item.target, { source: 'plugin-menu' })} - /> - - - resolveGitAuth(null)} - /> - resolveSigningPassphrase(null)} - /> - - setMenuletOpen(o => !o)} - onClose={() => setMenuletOpen(false)} - onOpenHub={openMissionControl} - onOpenAgent={handleMcOpen} - onPauseAgent={handleMcPause} - onResumeAgent={handleMcResume} - onSpawnAgent={handleMcSpawn} - onRespondRequest={bridges.respondUserRequest} - /> + setMenuletOpen(o => !o)} + onClose={() => setMenuletOpen(false)} + onOpenHub={openMissionControl} + onOpenAgent={handleMcOpen} + onPauseAgent={handleMcPause} + onResumeAgent={handleMcResume} + onSpawnAgent={handleMcSpawn} + onRespondRequest={bridges.respondUserRequest} + /> - + -
- {tweaks.drawerPosition === 'left' && workspacesPanel} +
+ {effectiveDrawerPosition === 'left' && workspacesPanel}
{splitVisible ? ( @@ -3088,7 +3660,7 @@ export default function App() {
{pluginSidebar}
- {tweaks.drawerPosition === 'right' && workspacesPanel} + {effectiveDrawerPosition === 'right' && workspacesPanel}
- {tweaks.drawerPosition === 'bottom' && workspacesPanel} + {effectiveDrawerPosition === 'bottom' && workspacesPanel} { setActiveWs(id); setDrawerOpen(false) }} + onAdded={(id) => { handleWsSelect(id); setDrawerOpen(false) }} /> {crewSession && ( @@ -3182,12 +3754,25 @@ export default function App() { setPromptPickerOpen(false)} + onClose={closePromptPicker} prompts={promptLib.prompts} + skills={promptBuilderLib.skills} seed={{ repo: activeWorkspace?.name ?? '', branch: effectiveBranch }} onInsert={(body, p) => { promptLib.incUsage('prompts', p.id) - insertPromptIntoComposer(body, p) + if (promptPickerTarget) { + composerDraftActions().set(promptPickerTarget.composerId, current => current ? `${current}\n\n${body}` : body) + } else { + insertPromptIntoComposer(body, p) + } + }} + onToggleSkill={(skill) => { + const sessionId = promptPickerTarget?.sessionId ?? promptBuilderSessionId + if (!sessionId) return + const enabled = toggleSkillForSession(sessionId, skill.id) + if (enabled === null) return + promptLib.incUsage('skills', skill.id) + handleApplySkill({ ...skill, enabled }) }} /> @@ -3221,8 +3806,16 @@ export default function App() { setTweak('density', v as TweakConfig['density'])} /> - setTweak('drawerPosition', v as TweakConfig['drawerPosition'])} /> - {tweaks.drawerPosition === 'bottom' + { + if (mobile.isMobile) setMobileDrawerSide(v === 'right' ? 'right' : 'left') + else setTweak('drawerPosition', v as TweakConfig['drawerPosition']) + }} + /> + {effectiveDrawerPosition === 'bottom' ? setTweak('drawerHeight', v)} /> : setTweak('drawerWidth', v)} />} @@ -3248,8 +3841,9 @@ export default function App() {
)} - -
+ +
+
) } diff --git a/src/renderer/src/components/canvas/CanvasMode.tsx b/src/renderer/src/components/canvas/CanvasMode.tsx index f4b7203..606c8bf 100644 --- a/src/renderer/src/components/canvas/CanvasMode.tsx +++ b/src/renderer/src/components/canvas/CanvasMode.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import { Icon } from '../ui/Icon' import { useSettings } from '../../hooks/useSettings' +import { useMobileLayout } from '../../hooks/useMobileLayout' export type CanvasPaneKind = 'chat' | 'terminal' @@ -28,13 +29,43 @@ interface CanvasModeProps { export function CanvasMode({ workspaceName, openChatCount, openTerminalCount, panes, onNewChat, onNewTerminal, onClosePane }: CanvasModeProps) { const { state: settings, set: setSetting } = useSettings() + const { isMobile } = useMobileLayout() + const [fabOpen, setFabOpen] = useState(false) + // Per-pane overflow menu. `null` = closed; otherwise the open pane's id. + const [paneMenuId, setPaneMenuId] = useState(null) + const paneMenuRef = useRef(null) + + useEffect(() => { + if (!paneMenuId) return + const onDown = (e: MouseEvent | TouchEvent): void => { + if (paneMenuRef.current && !paneMenuRef.current.contains(e.target as Node)) { + setPaneMenuId(null) + } + } + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setPaneMenuId(null) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('touchstart', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('touchstart', onDown) + document.removeEventListener('keydown', onKey) + } + }, [paneMenuId]) + return (
Workbench Mode
- {openChatCount} chats · {openTerminalCount} terminals open + + {isMobile + ? `${openChatCount + openTerminalCount} open` + : `${openChatCount} chats · ${openTerminalCount} terminals open`} +
{panes.length === 0 ? ( @@ -102,14 +133,43 @@ export function CanvasMode({ workspaceName, openChatCount, openTerminalCount, pa {settings.hideVerboseAgentLogs ? 'replies' : 'logs'} )} -
Add chat
-
Add terminal
- - -
{pane.content}
- - ))} +
{pane.content}
+ + ) + })} + + )} + + {/* Phone-only FAB: in the empty-hub case the start cards already expose + the Add buttons. Once the user has at least one pane, the only way + to add another is from inside that pane's title bar. On phones that + bar overflows, so we surface a sticky FAB with the same actions. */} + {isMobile && panes.length > 0 && ( +
+ {fabOpen && ( +
+ + +
+ )} +
)}
diff --git a/src/renderer/src/components/canvas/mobile-canvas-mode.test.ts b/src/renderer/src/components/canvas/mobile-canvas-mode.test.ts new file mode 100644 index 0000000..1515a19 --- /dev/null +++ b/src/renderer/src/components/canvas/mobile-canvas-mode.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') +const canvas = readFileSync(join(__dirname, 'CanvasMode.tsx'), 'utf8') + +describe('mobile canvas mode layout', () => { + it('renders the Add chat / Add terminal controls as buttons (a11y)', () => { + expect(canvas).toMatch(/ + + +
+ + +
+ + {destinationTab === 'new' ? ( +
+ Start a clean destination + Choose the provider, model, and effort for this workspace. +
+ ) : ( +
+ {targetSessions.length === 0 ?

No other used chats are available in this workspace.

: null} + {targetSessions.map(session => ( + + ))} +
+ )} + +
+ + + +
+ + {selectedExisting ?

This used chat keeps its selected provider, model, effort, and existing transcript.

: null} + {error ?
{error}
: null} + +
+ + +
+ + + ) +} diff --git a/src/renderer/src/components/chat/SoloChatView.tsx b/src/renderer/src/components/chat/SoloChatView.tsx index 0c57b54..3d9c6d1 100644 --- a/src/renderer/src/components/chat/SoloChatView.tsx +++ b/src/renderer/src/components/chat/SoloChatView.tsx @@ -8,6 +8,7 @@ import { ActiveSkillsStrip } from '../promptBuilder/ActiveSkillsStrip' import { AgentActivityOverlay } from '../thread/AgentActivityOverlay' import { CustodyHaltBanner } from './CustodyHaltBanner' import { latestTodoActivity } from '../thread/todo-from-toolcall' +import { CREWCODER_APPROVE_PLAN_PROMPT, latestCrewCoderPlanGate } from '../thread/crewcoder-plan-gate' import { useIsDark } from '../../hooks/useIsDark' import logoDark from '../../assets/icon-logo-dark.png' import logoLight from '../../assets/icon-logo-light.png' @@ -19,6 +20,8 @@ import type { Mode } from '../composer/ModeSegment' 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' type ThreadView = 'chat' | 'code' | 'md' @@ -57,6 +60,7 @@ export interface SoloChatViewProps { onStartCrew: () => void onOpenCanvas?: () => void onOpenTerminal?: () => void + onHandoff?: () => void // Composer composerMode: Mode setComposerMode: (m: Mode) => void @@ -83,6 +87,8 @@ export interface SoloChatViewProps { setModel: (m: string) => void effort: EffortLevel setEffort: (e: EffortLevel) => void + crewcoderMode?: CrewCoderMode + setCrewCoderMode: (mode: CrewCoderMode | undefined) => void // MCP — registry + this session's opt-in selection. Picker hidden when disabled. mcpEnabled?: boolean mcpServers?: McpServerConfig[] @@ -91,6 +97,8 @@ export interface SoloChatViewProps { shortcutOverrides: any /** Switch to the code view and reveal the given file in the editor. */ onOpenFile?: (path: string) => void + /** Open one turn/file directly in the turn changes drawer. */ + onOpenTurnChange?: (target: TurnChangeTarget) => void /** File to focus when the code view mounts (relative to workspace root). */ editorInitialFile?: string | null /** Right-click on the thread area — used to open the chat context menu. */ @@ -141,11 +149,11 @@ export function SoloChatView(props: SoloChatViewProps) { pendingGitDiff, setPendingGitDiff, hideHeader = false, agentLabel, modelLabel, voiceControl, - gitOpen, setGitOpen, github, dirtyCount = 0, changesOpen, changesCount, toggleChangesOpen, onStartCrew, onOpenCanvas, onOpenTerminal, + 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, + agents, activeAgentId, setActiveAgentId, model, setModel, effort, setEffort, crewcoderMode, setCrewCoderMode, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, - shortcutOverrides, onOpenFile, editorInitialFile, onThreadContextMenu, onOpenPrompts, onOpenBrowser, + shortcutOverrides, onOpenFile, onOpenTurnChange, editorInitialFile, onThreadContextMenu, onOpenPrompts, onOpenBrowser, delegationEnabled, onToggleDelegation, modePromptsEnabled, modePromptsLocked, onToggleModePrompts, pluginChatHeaderItems = [], onPluginChatHeaderItem, @@ -207,6 +215,7 @@ export function SoloChatView(props: SoloChatViewProps) { }, [messages, scrollToThreadBottom, threadView, updateBottomState]) const todoActivity = useMemo(() => latestTodoActivity(messages), [messages]) + const planGate = useMemo(() => latestCrewCoderPlanGate(messages), [messages]) const sentMessageHistory = useMemo(() => ( messages .flatMap(message => { @@ -258,6 +267,8 @@ export function SoloChatView(props: SoloChatViewProps) { onSelectModel={setModel} effort={effort} onSelectEffort={setEffort} + crewcoderMode={crewcoderMode} + onSelectCrewCoderMode={setCrewCoderMode} mcpEnabled={mcpEnabled} mcpServers={mcpServers} selectedMcpIds={selectedMcpIds} @@ -304,6 +315,7 @@ export function SoloChatView(props: SoloChatViewProps) { onOpenCanvas={onOpenCanvas} onOpenTerminal={onOpenTerminal} onOpenBrowser={onOpenBrowser} + onHandoff={onHandoff} delegationEnabled={delegationEnabled} onToggleDelegation={onToggleDelegation} modePromptsEnabled={modePromptsEnabled} @@ -350,7 +362,7 @@ export function SoloChatView(props: SoloChatViewProps) { {threadView === 'chat' && ( messages.length === 0 ?
start typing below to begin a chat in {workspace.name}
- : + : )} {threadView === 'md' && } @@ -373,13 +385,15 @@ export function SoloChatView(props: SoloChatViewProps) { {custodyHalt && onReauthorizeCustody && ( )} - {(agentRequest || todoActivity) && ( + {(agentRequest || todoActivity || planGate) && (
onRunCommand?.(CREWCODER_APPROVE_PLAN_PROMPT)} />
)} diff --git a/src/renderer/src/components/chat/handoff-card.test.ts b/src/renderer/src/components/chat/handoff-card.test.ts new file mode 100644 index 0000000..05a5a2c --- /dev/null +++ b/src/renderer/src/components/chat/handoff-card.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const card = readFileSync(fileURLToPath(new URL('./HandoffCard.tsx', import.meta.url)), 'utf8') +const pane = readFileSync(fileURLToPath(new URL('./ChatPane.tsx', import.meta.url)), 'utf8') +const styles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') + +describe('provider context handoff destination', () => { + it('uses the main-process transcript key for the source chat', () => { + expect(pane).toContain('bridges.handoff(started.bridgeId, `thread:${sourceSession.id}`') + expect(pane).not.toContain('bridges.handoff(started.bridgeId, sourceSession.id,') + }) + + it('separates new and used destinations into tabs and loads used chats on demand', () => { + expect(card).toContain("useState<'new' | 'used'>('new')") + expect(card).toContain('role="tablist"') + expect(card).toContain('>New chat') + expect(card).toContain('>Chats {targetSessions.length}') + expect(card).toContain("destinationTab === 'used'") + expect(card).toContain('targetSessions.map(session =>') + expect(styles).toContain('.handoff-card-tabs {') + }) + + it('uses the workspace Sessions catalogue and the destination owner tab', () => { + expect(pane).toContain('sessions={workspaceSessions}') + expect(pane).toContain('workspaceSessions.find(session => session.id === selection.targetSessionId)') + expect(pane).toContain('chatSessions.activate(target.tabId, target.id)') + expect(pane).toContain('const targetPath = resolveHandoffSessionPath(target)') + }) + + it('closes the card as soon as either destination transfer starts', () => { + expect(pane).toContain('setHandoffBusy(true)\n setHandoffError(null)\n setHandoffOpen(false)') + expect(pane).not.toContain('if (result.ok) setHandoffOpen(false)') + expect(pane).toContain("message: result.ok ? 'handoff complete' : (result.error ?? 'handoff failed')") + }) + + it('requires a selected used chat before enabling handoff', () => { + expect(card).toContain("destinationTab === 'new' || !!selectedExisting") + expect(card).toContain("destinationTab === 'new' ? 'new' : selectedExisting!.id") + }) +}) diff --git a/src/renderer/src/components/chat/mobile-chat-layout.test.ts b/src/renderer/src/components/chat/mobile-chat-layout.test.ts new file mode 100644 index 0000000..4b6cae9 --- /dev/null +++ b/src/renderer/src/components/chat/mobile-chat-layout.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') +const composer = readFileSync(join(__dirname, '../composer/Composer.tsx'), 'utf8') +const header = readFileSync(join(__dirname, '../thread/ChatHeader.tsx'), 'utf8') + +describe('mobile solo chat layout', () => { + it('uses a compact one-line header with a top-right actions menu', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.thr-h \{[\s\S]*?flex-wrap: nowrap;/) + expect(styles).toContain('.thr-h .actions .act-menu-wrap .act-label { display: none; }') + expect(header).toContain('mobile-chat-actions-trigger') + expect(styles).toContain('.thr-h .actions .act-menu-wrap .mobile-chat-actions-trigger {') + expect(styles).toContain('height: 22px;') + expect(styles).toContain('min-height: 36px;') + expect(header).toContain("window.innerWidth < COLLAPSE_WIDTH") + }) + + it('replaces the mobile model reveal with compact model and action menus', () => { + expect(composer).toContain(' { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.composer \{ width: 100%;/) + expect(styles).toMatch(/\.composer-wrap \{ padding: 4px 6px/) + expect(styles).toMatch(/\.composer textarea \{[^}]*font-size: 16px;/) + expect(styles).toContain('.mobile-composer-action-button,') + expect(styles).toContain('.mobile-composer-model-button {') + }) + + it('keeps embedded terminal and Git side panes out of the solo-chat column', () => { + expect(styles).toContain('.main > .termcol-outer,') + expect(styles).toContain('.chat-pane-row > .gs { display: none !important; }') + }) +}) diff --git a/src/renderer/src/components/composer/Composer.tsx b/src/renderer/src/components/composer/Composer.tsx index dadf528..b9c67a4 100644 --- a/src/renderer/src/components/composer/Composer.tsx +++ b/src/renderer/src/components/composer/Composer.tsx @@ -16,8 +16,10 @@ 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 { ComposerDictationButton } from './ComposerDictationButton' import { insertDictationText } from './composer-dictation-text' +import { MobileComposerActionMenu, MobileComposerModelMenu } from './MobileComposerMenus' const MODE_CYCLE: Mode[] = ['Ask', 'Plan', 'Build', 'Full'] @@ -65,6 +67,8 @@ interface ComposerProps { effort: EffortLevel onSelectEffort: (e: EffortLevel) => void + crewcoderMode?: CrewCoderMode + onSelectCrewCoderMode: (mode: CrewCoderMode | undefined) => void mcpEnabled?: boolean mcpServers?: McpServerConfig[] @@ -156,7 +160,7 @@ export function Composer({ sentMessageHistory = [], isRunning, onStop, voiceControl, dictationScopeId, agents, activeAgentId, onSelectAgent, - model, onSelectModel, effort, onSelectEffort, + model, onSelectModel, effort, onSelectEffort, crewcoderMode, onSelectCrewCoderMode, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, shortcutOverrides, attachments: attachmentsProp, onAttachmentsChange, @@ -168,12 +172,14 @@ export function Composer({ onToggleSkillEnabled, branchPicker, }: ComposerProps) { + const executionModeDisabled = crewCoderProfileLocksExecutionMode(activeAgentId, crewcoderMode) const taRef = useRef(null) const valueRef = useRef(value) valueRef.current = value const modelRowRef = useRef(null) - // Keep the hover-revealed model row pinned open while a picker dropdown is up. + // Desktop keeps the hover row open while one of its pickers is active. const [modelPickerOpen, setModelPickerOpen] = useState(false) + const inputBlurTimerRef = useRef(null) const fileInputRef = useRef(null) const historyIndexRef = useRef(-1) const historyDraftRef = useRef('') @@ -366,6 +372,10 @@ export function Composer({ .map(s => ({ id: `skill:${s.id}`, kind: 'skill' as const, title: s.title, description: s.description, skill: s })) const builtInCommands = [ { id: 'builtin:compact', kind: 'command' as const, title: '/compact', description: 'compact the current provider session', body: '/compact' }, + { id: 'builtin:handoff', kind: 'command' as const, title: '/handoff', description: 'hand off context to a new or used chat', body: '/handoff' }, + ...(activeAgentId === 'crewcoder' + ? [{ id: 'builtin:approve-plan', kind: 'command' as const, title: '/approve-plan', description: 'approve the current CrewCoder-mode plan', body: '/approve-plan' }] + : []), { id: 'builtin:add-dir', kind: 'command' as const, title: '/add-dir', description: 'attach an external directory to this session', body: '/add-dir' }, { id: 'builtin:remove-dir', kind: 'command' as const, title: '/remove-dir', description: 'remove an external directory from this session', body: '/remove-dir' }, ].filter(c => slashCategory && slashCategory !== 'command' ? false : (!search || c.title.toLowerCase().includes(search) || c.description.toLowerCase().includes(search))) @@ -373,7 +383,7 @@ export function Composer({ .filter(c => slashCategory && slashCategory !== 'command' ? false : (!search || c.name.toLowerCase().includes(search) || c.description.toLowerCase().includes(search))) .map(c => ({ id: `command:${c.id}`, kind: 'command' as const, title: c.name, description: c.description, body: c.body, command: c })) return [...builtInCommands, ...commandItems, ...promptItems, ...skillItems].slice(0, 50) - }, [prompts, skills, commands, slash?.query, slashCategory]) + }, [prompts, skills, commands, slash?.query, slashCategory, activeAgentId]) const pickSlash = (itemId: string) => { const ta = taRef.current @@ -388,7 +398,7 @@ export function Composer({ // draft untouched, and dispatch the body straight to the agent. Built-in // commands like /compact have no `command` and fall through to be inserted, // since they need send()'s special-case handling on Enter. - if (item.kind === 'command' && onRunCommand && (item.command || item.id === 'builtin:add-dir' || item.id === 'builtin:remove-dir')) { + if (item.kind === 'command' && onRunCommand && (item.command || item.id === 'builtin:handoff' || item.id === 'builtin:approve-plan' || item.id === 'builtin:add-dir' || item.id === 'builtin:remove-dir')) { const next = (before + after).trimStart() onChange(next) setSlash(null) @@ -604,7 +614,18 @@ export function Composer({ onKeyUp={updateMentionFromCaret} onClick={updateMentionFromCaret} onPaste={onPaste} - onBlur={() => setTimeout(() => { setMention(null); setSlash(null) }, 120)} + onFocus={() => { + if (inputBlurTimerRef.current !== null) window.clearTimeout(inputBlurTimerRef.current) + inputBlurTimerRef.current = null + }} + onBlur={() => { + if (inputBlurTimerRef.current !== null) window.clearTimeout(inputBlurTimerRef.current) + inputBlurTimerRef.current = window.setTimeout(() => { + inputBlurTimerRef.current = null + setMention(null) + setSlash(null) + }, 120) + }} /> {mention && (
+
- - {branchPicker && ( - fileInputRef.current?.click()} + > + + + + {branchPicker && ( + + )} +
+
+ fileInputRef.current?.click()} + onOpenPrompts={onOpenPrompts} + branchPicker={branchPicker} /> - )} + +
{dictationScopeId ? (
- {/* Hover the thin strip at the bottom of the composer to slide the model - row down; it collapses when unhovered, but stays open while a picker - dropdown is active. */} + {/* Desktop reveals this row from the lower hover strip. Mobile replaces + it with the selected-model button in the main toolbar. */}
@@ -695,6 +740,10 @@ export function Composer({ model={model} onSelectModel={onSelectModel} effort={effort} + crewcoderMode={crewcoderMode} + onSelectCrewCoderMode={onSelectCrewCoderMode} + crewcoderModeDisabled={isRunning} + executionModeDisabled={executionModeDisabled} mode={mode} setMode={setMode} onSelectEffort={onSelectEffort} diff --git a/src/renderer/src/components/composer/CrewCoderModePicker.test.ts b/src/renderer/src/components/composer/CrewCoderModePicker.test.ts new file mode 100644 index 0000000..f472a1d --- /dev/null +++ b/src/renderer/src/components/composer/CrewCoderModePicker.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { crewCoderModeItems } from './CrewCoderModePicker' + +describe('CrewCoder mode picker', () => { + it('shows every CrewCoder ACP mode in stable order', () => { + expect(crewCoderModeItems().map(item => item.id)).toEqual([ + '', 'general', 'crewcoder', 'plugin', 'extension', + ]) + }) +}) diff --git a/src/renderer/src/components/composer/CrewCoderModePicker.tsx b/src/renderer/src/components/composer/CrewCoderModePicker.tsx new file mode 100644 index 0000000..dd4df6b --- /dev/null +++ b/src/renderer/src/components/composer/CrewCoderModePicker.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { Icon } from '../ui/Icon' +import { PickerSheet } from './PickerSheet' +import { CREWCODER_MODES, type CrewCoderMode } from '../../../../shared/crewcoder-types' + +const MODE_DETAILS: Record = { + general: { label: 'General', sub: 'General-purpose coding agent' }, + crewcoder: { label: 'CrewCoder', sub: 'Architect, plan, and approve' }, + plugin: { label: 'Plugin', sub: 'Build CrewCode plugins with plugin tools and docs' }, + extension: { label: 'Extension', sub: 'Build CrewCoder extensions with extension tools and docs' }, +} + +export function crewCoderModeItems() { + return [ + { id: '', label: 'Configured default', sub: 'Use CrewCoder’s saved default mode' }, + ...CREWCODER_MODES.map(id => ({ id, ...MODE_DETAILS[id] })), + ] +} + +interface CrewCoderModePickerProps { + open: boolean + anchor: HTMLElement | null + value?: CrewCoderMode + onPick: (mode: CrewCoderMode | undefined) => void + onClose: () => void +} + +export function CrewCoderModePicker({ open, anchor, value, onPick, onClose }: CrewCoderModePickerProps) { + return ( + onPick(id ? id as CrewCoderMode : undefined)} + defaultIcon={} + width={320} + /> + ) +} diff --git a/src/renderer/src/components/composer/MobileComposerMenus.tsx b/src/renderer/src/components/composer/MobileComposerMenus.tsx new file mode 100644 index 0000000..1fd4dc6 --- /dev/null +++ b/src/renderer/src/components/composer/MobileComposerMenus.tsx @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { Icon } from '../ui/Icon' +import { CreateBranchModal } from '../git/BranchPicker' +import type { GitBranchRef } from '../git/git-state' +import { prefetchProviderModels, useProviderModels } from '../../hooks/useProviderModels' +import type { McpServerConfig } from '../../hooks/useSettings' +import type { AgentInfo } from '../../types' +import { effortRowsForProvider, providerSupportsEffort, type EffortLevel } from './EffortPicker' +import type { Mode } from './ModeSegment' +import { PickerSheet, type PickerItem } from './PickerSheet' +import { PROVIDER_IMAGES, PROVIDER_META, providerImageClass } from './provider-meta' + +const MODE_COPY: Record = { + Ask: 'Read-only answers and discovery', + Plan: 'Fresh context and a markdown plan', + Build: 'Careful implementation with approvals', + Full: 'All tools pre-approved', +} + +const EFFORT_LABEL: Record = { + off: 'Off', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max', ultra: 'Ultra', +} + +function shortModel(id: string): string { + if (!id) return 'auto' + const slash = id.lastIndexOf('/') + return slash >= 0 ? id.slice(slash + 1) : id +} + +function providerIcon(provider: string, size = 18) { + const image = PROVIDER_IMAGES[provider] + return image + ? + : +} + +type ModelPage = 'root' | 'provider' | 'model' | 'effort' | 'mode' | 'mcp' + +interface MobileModelMenuProps { + agents: AgentInfo[] + activeAgentId: string + onSelectAgent: (id: string) => void + model: string + onSelectModel: (model: string) => void + effort: EffortLevel + onSelectEffort: (effort: EffortLevel) => void + mode: Mode + setMode: (mode: Mode) => void + executionModeDisabled?: boolean + mcpEnabled?: boolean + mcpServers?: McpServerConfig[] + selectedMcpIds?: string[] + onToggleMcp?: (id: string) => void +} + +export function MobileComposerModelMenu({ + agents, activeAgentId, onSelectAgent, + model, onSelectModel, effort, onSelectEffort, mode, setMode, executionModeDisabled = false, + mcpEnabled = false, mcpServers = [], selectedMcpIds = [], onToggleMcp, +}: MobileModelMenuProps) { + const anchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [page, setPage] = useState('root') + const { list: models, loading } = useProviderModels(activeAgentId, open, open) + const activeProvider = agents.find(agent => agent.id === activeAgentId) + const selectedModel = models.find(item => item.id === model) + const mcpCount = selectedMcpIds.filter(id => mcpServers.some(server => server.id === id)).length + + useEffect(() => { if (!open) setPage('root') }, [open]) + + const rootItems = useMemo(() => [ + { + id: 'provider', label: 'Provider', + sub: activeProvider?.name ?? activeAgentId, + icon: providerIcon(activeAgentId), + }, + { + id: 'model', label: 'Model', + sub: selectedModel?.label ?? shortModel(model), + icon: , + }, + { + id: 'effort', label: 'Effort', + sub: EFFORT_LABEL[effort], + icon: , + disabled: effortRowsForProvider(activeAgentId).length === 0, + }, + { + id: 'mode', label: 'Mode', + sub: executionModeDisabled ? 'Build · locked by CrewCoder profile' : (mode === 'Full' ? 'Full Access' : mode), + icon: , + disabled: executionModeDisabled, + }, + ...(mcpEnabled ? [{ + id: 'mcp', label: 'MCP servers', + sub: mcpCount > 0 ? `${mcpCount} selected` : 'none selected', + icon: , + }] : []), + ], [activeAgentId, activeProvider?.name, effort, executionModeDisabled, mcpCount, mcpEnabled, mode, model, selectedModel?.label]) + + const pageItems = useMemo(() => { + const back: PickerItem = { id: '__back', label: 'Back', sub: 'Model settings', icon: } + if (page === 'provider') return [back, ...agents.map(agent => ({ + id: agent.id, + label: agent.name, + sub: agent.description ?? (agent.available ? 'available' : 'not available'), + disabled: !agent.available, + icon: providerIcon(agent.id), + }))] + if (page === 'model') return [back, ...models.map(item => ({ + id: item.id || '__default_model', + label: item.label || 'auto', + sub: item.id || 'Provider default', + icon: providerIcon(item.provider || activeAgentId), + }))] + if (page === 'effort') return [back, ...effortRowsForProvider(activeAgentId).map(item => ({ + id: item.id, label: item.label, sub: item.sub, icon: , + }))] + if (page === 'mode') return [back, ...(['Ask', 'Plan', 'Build', 'Full'] as Mode[]).map(item => ({ + id: item, label: item === 'Full' ? 'Full Access' : item, sub: MODE_COPY[item], icon: , + }))] + if (page === 'mcp') return [back, ...mcpServers.map(server => ({ + id: server.id, label: server.name, sub: [server.command, ...(server.args ?? [])].join(' '), icon: , + }))] + return rootItems + }, [activeAgentId, agents, mcpServers, models, page, rootItems]) + + const pick = (id: string) => { + if (id === '__back') { setPage('root'); return } + if (page === 'root') { setPage(id as ModelPage); return } + if (page === 'provider') { + void prefetchProviderModels(id, true) + if (!providerSupportsEffort(id, effort)) onSelectEffort(effortRowsForProvider(id)[0]?.id ?? 'off') + onSelectAgent(id) + setPage('root') + return + } + if (page === 'model') { onSelectModel(id === '__default_model' ? '' : id); setPage('root'); return } + if (page === 'effort') { onSelectEffort(id as EffortLevel); setPage('root'); return } + if (page === 'mode' && !executionModeDisabled) { setMode(id as Mode); setPage('root'); return } + if (page === 'mcp') onToggleMcp?.(id) + } + + const header = page === 'root' ? 'MODEL SETTINGS' : page.toUpperCase() + const activeId = page === 'provider' ? activeAgentId + : page === 'model' ? (model || '__default_model') + : page === 'effort' ? effort + : page === 'mode' ? mode + : undefined + + return ( + <> + + setOpen(false)} + anchor={anchorRef.current} + header={header} + items={pageItems} + activeId={activeId} + multiSelect={page === 'mcp'} + selectedIds={page === 'mcp' ? selectedMcpIds : undefined} + onPick={pick} + closeOnPick={false} + className="mobile-composer-menu-sheet" + emptyLabel={page === 'mcp' ? 'No MCP servers configured' : 'No options available'} + width={330} + /> + + ) +} + +type ActionPage = 'root' | 'branches' + +interface MobileActionMenuProps { + onAttach: () => void + onOpenPrompts?: () => void + branchPicker?: { + currentBranch: string + branches: GitBranchRef[] + onCheckoutBranch?: (ref: string) => void + onCreateBranch?: (name: string) => void + onRefresh?: () => void + } +} + +export function MobileComposerActionMenu({ onAttach, onOpenPrompts, branchPicker }: MobileActionMenuProps) { + const anchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [page, setPage] = useState('root') + const [createBranchOpen, setCreateBranchOpen] = useState(false) + + useEffect(() => { if (!open) setPage('root') }, [open]) + useEffect(() => { if (open && page === 'branches') branchPicker?.onRefresh?.() }, [branchPicker, open, page]) + + const items = useMemo(() => { + if (page === 'root') return [ + { id: 'attach', label: 'Attach files', sub: 'Add files or images to this message', icon: }, + { id: 'prompts', label: 'Prompts & Skills', sub: 'Browse your prompt library', icon: , disabled: !onOpenPrompts }, + ...(branchPicker ? [{ id: 'branches', label: 'Branch', sub: branchPicker.currentBranch, icon: }] : []), + ] + if (!branchPicker) return [] + return [ + { id: '__back', label: 'Back', sub: 'Composer actions', icon: }, + ...branchPicker.branches.map(item => ({ + id: `branch:${item.name}`, + label: item.name, + sub: `${item.kind}${item.updated ? ` · ${item.updated}` : ''}`, + icon: , + })), + { id: '__create', label: 'Create branch…', sub: `From ${branchPicker.currentBranch}`, icon: }, + ] + }, [branchPicker, onOpenPrompts, page]) + + const pick = (id: string) => { + if (id === '__back') { setPage('root'); return } + if (page === 'root') { + if (id === 'attach') { setOpen(false); onAttach(); return } + if (id === 'prompts') { setOpen(false); onOpenPrompts?.(); return } + if (id === 'branches') { setPage('branches'); return } + } + if (id === '__create') { setOpen(false); setCreateBranchOpen(true); return } + if (id.startsWith('branch:')) { + setOpen(false) + const name = id.slice('branch:'.length) + const branch = branchPicker?.branches.find(item => item.name === name) + const ref = branch?.kind === 'remote' && !name.startsWith('origin/') ? `origin/${name}` : name + branchPicker?.onCheckoutBranch?.(ref) + } + } + + return ( + <> + + setOpen(false)} + anchor={anchorRef.current} + header={page === 'root' ? 'COMPOSER ACTIONS' : 'BRANCHES'} + items={items} + activeId={page === 'branches' ? `branch:${branchPicker?.currentBranch ?? ''}` : undefined} + onPick={pick} + closeOnPick={false} + className="mobile-composer-menu-sheet" + width={330} + /> + {branchPicker && ( + setCreateBranchOpen(false)} + /> + )} + + ) +} diff --git a/src/renderer/src/components/composer/ModeSegment.test.ts b/src/renderer/src/components/composer/ModeSegment.test.ts new file mode 100644 index 0000000..7bb7efa --- /dev/null +++ b/src/renderer/src/components/composer/ModeSegment.test.ts @@ -0,0 +1,23 @@ +import { createElement } from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { ModeSegment } from './ModeSegment' + +describe('ModeSegment locking', () => { + it('disables the execution-mode button while a CrewCoder profile owns behavior', () => { + let renderer!: TestRenderer.ReactTestRenderer + act(() => { + renderer = TestRenderer.create(createElement(ModeSegment, { + mode: 'Build', + onChange: vi.fn(), + disabled: true, + disabledReason: 'CrewCoder profile active', + })) + }) + + const button = renderer.root.findByType('button') + expect(button.props.disabled).toBe(true) + expect(button.props.title).toBe('CrewCoder profile active') + act(() => renderer.unmount()) + }) +}) diff --git a/src/renderer/src/components/composer/ModeSegment.tsx b/src/renderer/src/components/composer/ModeSegment.tsx index da8209c..ab14950 100644 --- a/src/renderer/src/components/composer/ModeSegment.tsx +++ b/src/renderer/src/components/composer/ModeSegment.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Icon, type IconName } from '../ui/Icon' import { PickerSheet, type PickerItem } from './PickerSheet' @@ -22,6 +22,8 @@ const MODE_META: Record = interface ModeSegmentProps { mode: Mode onChange: (m: Mode) => void + disabled?: boolean + disabledReason?: string } function modeItems(): PickerItem[] { @@ -34,21 +36,26 @@ function modeItems(): PickerItem[] { })) } -export function ModeSegment({ mode, onChange }: ModeSegmentProps) { +export function ModeSegment({ mode, onChange, disabled = false, disabledReason }: ModeSegmentProps) { const ref = useRef(null) const [open, setOpen] = useState(false) const meta = MODE_META[mode] + useEffect(() => { + if (disabled) setOpen(false) + }, [disabled]) + return ( <> + {showCrewCoderModes && ( + + )} + @@ -133,7 +169,7 @@ export const ModelRow = forwardRef(function Model className="model-btn" disabled={effortRowsForProvider(activeAgentId).length === 0} title={effortRowsForProvider(activeAgentId).length === 0 ? 'This provider does not expose reasoning-effort controls' : undefined} - onClick={() => { setEffortOpen(o => !o); setProvOpen(false); setModelOpen(false); setMcpOpen(false) }} + onClick={() => { setEffortOpen(o => !o); setProvOpen(false); setModelOpen(false); setMcpOpen(false); setCrewCoderModeOpen(false) }} > {EFFORT_LABEL[effort]} @@ -143,16 +179,30 @@ export const ModelRow = forwardRef(function Model )} + {showCrewCoderModes && ( + setCrewCoderModeOpen(false)} + anchor={crewCoderModeRef.current} + value={crewcoderMode} + onPick={onSelectCrewCoderMode} + /> + )}
- +
(null) @@ -85,7 +87,14 @@ export function PickerSheet({ // filter, contain) or clips with overflow — e.g. the crew config panel's // animated lane cards and overflow:hidden frame. return createPortal( -
+ <> +
-
, +
+ , document.body, ) } diff --git a/src/renderer/src/components/composer/mobile-composer-menus.test.ts b/src/renderer/src/components/composer/mobile-composer-menus.test.ts new file mode 100644 index 0000000..f0ed7e5 --- /dev/null +++ b/src/renderer/src/components/composer/mobile-composer-menus.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const menus = readFileSync(fileURLToPath(new URL('./MobileComposerMenus.tsx', import.meta.url)), 'utf8') +const composer = readFileSync(fileURLToPath(new URL('./Composer.tsx', import.meta.url)), 'utf8') +const picker = readFileSync(fileURLToPath(new URL('./PickerSheet.tsx', import.meta.url)), 'utf8') +const styles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') + +describe('mobile composer menus', () => { + it('shows the chosen model on one navigable settings button', () => { + expect(menus).toContain('className="mobile-composer-model-button"') + expect(menus).toContain("selectedModel?.label ?? shortModel(model)") + for (const page of ["'provider'", "'model'", "'effort'", "'mode'", "'mcp'"]) { + expect(menus).toContain(page) + } + expect(menus).toContain("header={header}") + expect(menus).toContain('closeOnPick={false}') + }) + + it('consolidates files, prompts, and branch controls under Actions', () => { + expect(menus).toContain('className="mobile-composer-action-button"') + expect(menus).toContain("label: 'Attach files'") + expect(menus).toContain("label: 'Prompts & Skills'") + expect(menus).toContain("label: 'Branch'") + expect(menus).toContain("label: 'Create branch…'") + expect(composer).toContain('onAttach={() => fileInputRef.current?.click()}') + }) + + it('keeps desktop controls and enables non-closing picker navigation', () => { + expect(composer).toContain('className="desktop-composer-actions"') + expect(styles).toContain('.desktop-composer-actions { display: flex;') + expect(styles).toContain('.mobile-composer-actions { display: none; }') + expect(styles).toContain('background-color: transparent !important;') + expect(styles).toContain('-webkit-appearance: none;') + expect(styles).toContain('box-shadow: none !important;') + expect(picker).toContain('closeOnPick = !multiSelect') + expect(picker).toContain('if (closeOnPick) onClose()') + }) +}) diff --git a/src/renderer/src/components/composer/mobile-picker-sheet.test.ts b/src/renderer/src/components/composer/mobile-picker-sheet.test.ts new file mode 100644 index 0000000..fdd98ea --- /dev/null +++ b/src/renderer/src/components/composer/mobile-picker-sheet.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const picker = readFileSync(fileURLToPath(new URL('./PickerSheet.tsx', import.meta.url)), 'utf8') +const styles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') +const mobileStyles = styles.slice(styles.indexOf('@media (max-width: 768px)')) + +describe('mobile composer picker sheets', () => { + it('provides a dismissible mobile backdrop', () => { + expect(picker).toContain('className="picker-sheet-backdrop"') + expect(picker).toContain('aria-label="Close picker"') + expect(picker).toContain('onClick={onClose}') + expect(styles).toContain('.picker-sheet-backdrop { display: none; }') + expect(mobileStyles).toMatch(/\.picker-sheet-backdrop \{[\s\S]*?display: block;/) + }) + + it('renders as a compact viewport-bounded bottom sheet on mobile', () => { + expect(mobileStyles).toMatch(/\.picker-sheet \{[\s\S]*?bottom: 0 !important;/) + expect(mobileStyles).toContain('max-height: min(54dvh, 410px) !important;') + expect(mobileStyles).toContain('.picker-sheet.model-picker-sheet { height: min(50dvh, 350px); }') + expect(mobileStyles).toContain('.picker-row { min-height: 40px;') + expect(mobileStyles).toContain('.picker-search input { min-width: 0; font-size: 16px; }') + }) +}) diff --git a/src/renderer/src/components/crew/CrewTimeline.tsx b/src/renderer/src/components/crew/CrewTimeline.tsx index 2b31b18..feaf33b 100644 --- a/src/renderer/src/components/crew/CrewTimeline.tsx +++ b/src/renderer/src/components/crew/CrewTimeline.tsx @@ -4,6 +4,7 @@ import { Icon } from '../ui/Icon' import { Messages } from '../thread/Messages' import { AgentActivityOverlay } from '../thread/AgentActivityOverlay' import { latestTodoActivity } from '../thread/todo-from-toolcall' +import { CREWCODER_APPROVE_PLAN_PROMPT, latestCrewCoderPlanGate } from '../thread/crewcoder-plan-gate' import { LaneComposer } from './LaneComposer' import { LaneModelButton } from './LaneModelButton' import { LaneRunSwitch } from './LaneRunSwitch' @@ -152,6 +153,7 @@ export function CrewTimeline({ const live = !!(lane.bridgeId || lane.paneId) const usage = lane.usage const todoActivity = i === rounds.length - 1 ? latestTodoActivity(group.messages) : null + const planGate = i === rounds.length - 1 ? latestCrewCoderPlanGate(group.messages) : null const request = (userRequestsByTab?.[lane.tabId ?? ''] ?? [])[0] const groupKey = `${i}:${lane.laneId}` const collapsed = collapsedGroups[groupKey] === true @@ -224,13 +226,15 @@ export function CrewTimeline({ {shortModel(lane.model)}
)} - {(request || todoActivity) && ( + {(request || todoActivity || planGate) && (
onSendToLane(lane.laneId, CREWCODER_APPROVE_PLAN_PROMPT)} />
)} diff --git a/src/renderer/src/components/crew/LaneColumn.tsx b/src/renderer/src/components/crew/LaneColumn.tsx index 9e56f6c..97ec157 100644 --- a/src/renderer/src/components/crew/LaneColumn.tsx +++ b/src/renderer/src/components/crew/LaneColumn.tsx @@ -6,6 +6,7 @@ import { useStickToBottom } from '../../hooks/useStickToBottom' import { XTermPane } from '../terminal/XTermPane' import { AgentActivityOverlay } from '../thread/AgentActivityOverlay' import { latestTodoActivity } from '../thread/todo-from-toolcall' +import { CREWCODER_APPROVE_PLAN_PROMPT, latestCrewCoderPlanGate } from '../thread/crewcoder-plan-gate' import { LaneComposer } from './LaneComposer' import { LaneModelButton } from './LaneModelButton' import { LaneRunSwitch } from './LaneRunSwitch' @@ -65,9 +66,10 @@ export function LaneColumn({ const live = !!(lane.bridgeId || lane.paneId) const usage = lane.usage const todoActivity = latestTodoActivity(messages) + const planGate = latestCrewCoderPlanGate(messages) // pty lanes render a raw terminal — they have no bridge requests or todo // stream, so the dock stays composer-only there. - const showActivity = !isPty && !!(agentRequest || todoActivity) + const showActivity = !isPty && !!(agentRequest || todoActivity || planGate) // pty agents take the lane model as a CLI flag — bridges apply it at spawn, // so this is the only place a pty lane's picked model reaches the process. const ptyArgv = isPty && lane.model ? ['--model', lane.model] : undefined @@ -234,6 +236,8 @@ export function LaneColumn({ isStreaming={todoActivity?.isStreaming ?? live} request={agentRequest ?? undefined} onRespond={onAgentRequestResponse} + planGate={planGate} + onApprovePlan={() => onSend(CREWCODER_APPROVE_PLAN_PROMPT)} /> )} diff --git a/src/renderer/src/components/crew/SupervisorSidebar.tsx b/src/renderer/src/components/crew/SupervisorSidebar.tsx index ef37ac3..b4075e8 100644 --- a/src/renderer/src/components/crew/SupervisorSidebar.tsx +++ b/src/renderer/src/components/crew/SupervisorSidebar.tsx @@ -4,6 +4,7 @@ import { Icon } from '../ui/Icon' import { Messages } from '../thread/Messages' import { AgentActivityOverlay } from '../thread/AgentActivityOverlay' import { latestTodoActivity } from '../thread/todo-from-toolcall' +import { CREWCODER_APPROVE_PLAN_PROMPT, latestCrewCoderPlanGate } from '../thread/crewcoder-plan-gate' import { MentionPopover } from '../composer/MentionPopover' import { PROVIDER_IMAGES, providerImageClass } from '../composer/provider-meta' import { useSettings } from '../../hooks/useSettings' @@ -80,6 +81,7 @@ export function SupervisorSidebar({ const busy = supe.status === 'thinking' || supe.status === 'delegating' const todoActivity = useMemo(() => latestTodoActivity(messages), [messages]) + const planGate = useMemo(() => latestCrewCoderPlanGate(messages), [messages]) const updateScrollBottomButton = () => { const el = threadRef.current @@ -219,13 +221,15 @@ export function SupervisorSidebar({
- {(agentRequest || todoActivity) && ( + {(agentRequest || todoActivity || planGate) && (
onSend(CREWCODER_APPROVE_PLAN_PROMPT)} />
)} diff --git a/src/renderer/src/components/crew/crew-transcript.ts b/src/renderer/src/components/crew/crew-transcript.ts index d760404..b365766 100644 --- a/src/renderer/src/components/crew/crew-transcript.ts +++ b/src/renderer/src/components/crew/crew-transcript.ts @@ -35,6 +35,7 @@ function messageToMarkdown(msg: Message): string | null { case 'handoff_summary': return `### ${msg.reason === 'compact' ? 'Compact' : 'Handoff'} summary\n\n${msg.summary}` case 'worklog': + case 'activity': case 'compaction': case 'handoff': return null diff --git a/src/renderer/src/components/editor/CodeEditor.tsx b/src/renderer/src/components/editor/CodeEditor.tsx index 505bf81..88c1c7d 100644 --- a/src/renderer/src/components/editor/CodeEditor.tsx +++ b/src/renderer/src/components/editor/CodeEditor.tsx @@ -14,6 +14,7 @@ import type { CodeFile } from '../../hooks/useEditorSessions' import type { RegisteredPluginEditorAction } from '../../../../shared/plugin-types' import type { CompletionProviderId } from '../../../../shared/agent-completion-types' import type { EditorThemeId } from '../../../../shared/editor-theme-types' +import { useMobileLayout } from '../../hooks/useMobileLayout' export type { CodeFile } @@ -252,12 +253,15 @@ export function CodeEditor({ completion, theme = 'crewcode', }: CodeEditorProps) { + const { isMobile } = useMobileLayout() const [err, setErr] = useState(null) const [busy, setBusy] = useState(false) const [saving, setSaving] = useState(false) const [diffView, setDiffView] = useState<{ title: string; diff: string } | null>(null) const [ftWidth, setFtWidth] = useState(220) - const [ftOpen, setFtOpen] = useState(true) + // Phones start with the code canvas unobstructed. The file tree remains one + // tap away as an off-canvas panel; desktop retains its persistent default. + const [ftOpen, setFtOpen] = useState(() => !isMobile) const [formatting, setFormatting] = useState(false) const [fmtNotice, setFmtNotice] = useState(null) const [editorCtx, setEditorCtx] = useState<{ x: number; y: number; start: number; end: number } | null>(null) @@ -1172,13 +1176,14 @@ export function CodeEditor({ {ftOpen && !problemsOpen && !referenceOpen && ( <> - + {isMobile && ) } -export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnstage, onStageAll, onUnstageAll }: GitPageChangesProps) { +export function GitPageChanges({ repoPath, comparisonRef, changes, hasUnpushed, onStage, onUnstage, onStageAll, onUnstageAll }: GitPageChangesProps) { const staged = useMemo(() => changes.filter(change => change.staged), [changes]) const unstaged = useMemo(() => changes.filter(change => !change.staged), [changes]) const [selected, setSelected] = useState(null) const [diff, setDiff] = useState<{ loading: boolean; patch: string; error?: string }>({ loading: false, patch: '' }) useEffect(() => { - if (!selected || changes.every(change => change.path !== selected.path || change.staged !== selected.staged)) { + const current = selected + ? changes.find(change => change.path === selected.path && change.staged === selected.staged) + : undefined + if (!selected || !current) { const first = changes[0] - setSelected(first ? { path: first.path, staged: first.staged, title: `${first.staged ? 'staged' : 'unstaged'}: ${first.path}` } : null) + setSelected(first ? { + path: first.path, + staged: first.staged, + title: comparisonRef ? `vs ${comparisonRef}: ${first.path}` : `${first.staged ? 'staged' : 'unstaged'}: ${first.path}`, + } : null) + return } - }, [changes, selected]) + const title = comparisonRef ? `vs ${comparisonRef}: ${current.path}` : `${current.staged ? 'staged' : 'unstaged'}: ${current.path}` + if (selected.title !== title) setSelected({ ...selected, title }) + }, [changes, comparisonRef, selected]) useEffect(() => { let cancelled = false @@ -74,7 +88,11 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst return } setDiff({ loading: true, patch: '' }) - window.electronAPI?.gitDiff(repoPath, selected.path, selected.staged) + const client = getCrewCodeClient() + const request = comparisonRef + ? client.gitDiffVsRef(repoPath, comparisonRef, selected.path) + : client.gitDiff(repoPath, selected.path, selected.staged) + request .then(result => { if (cancelled) return if (result?.error) setDiff({ loading: false, patch: '', error: result.error }) @@ -84,10 +102,14 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst if (!cancelled) setDiff({ loading: false, patch: '', error: String(error) }) }) return () => { cancelled = true } - }, [repoPath, selected]) + }, [repoPath, comparisonRef, selected]) const select = (change: GitChange) => { - setSelected({ path: change.path, staged: change.staged, title: `${change.staged ? 'staged' : 'unstaged'}: ${change.path}` }) + setSelected({ + path: change.path, + staged: change.staged, + title: comparisonRef ? `vs ${comparisonRef}: ${change.path}` : `${change.staged ? 'staged' : 'unstaged'}: ${change.path}`, + }) } return ( @@ -95,7 +117,7 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst
- Changes + {comparisonRef ? `Changes vs ${comparisonRef}` : 'Changes'}

{changes.length || 'No'} changed file{changes.length === 1 ? '' : 's'}

{hasUnpushed && ahead} @@ -108,7 +130,15 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst )} {unstaged.length > 0 && (
-
Changes · {unstaged.length}
+
+ {comparisonRef ? `Changes vs ${comparisonRef}` : 'Changes'} · {unstaged.length} + {unstaged.some(change => change.stageable !== false) && ( + + )} +
{unstaged.map(change => select(change)} onStage={onStage} onUnstage={onUnstage} />)}
)} diff --git a/src/renderer/src/components/git/GitSidebar.tsx b/src/renderer/src/components/git/GitSidebar.tsx index 5bead9f..540b4b1 100644 --- a/src/renderer/src/components/git/GitSidebar.tsx +++ b/src/renderer/src/components/git/GitSidebar.tsx @@ -89,6 +89,7 @@ interface TopBarProps { onOpenTerminal?: (path: string) => void onCheckoutBranch?: (ref: string) => void onCreateBranch?: (name: string) => void + onClose?: () => void } /** git/ssh remote → browsable https URL (git@host:org/repo.git → https://host/org/repo). */ @@ -103,7 +104,7 @@ function remoteWebUrl(remote: string): string | null { } function TopBar({ workspace, branches, ahead, behind, lastFetch, fetching, remoteUrl, - onPush, onPull, onFetch, onSync, onCreatePR, onOpenTerminal, onCheckoutBranch, onCreateBranch }: TopBarProps) { + onPush, onPull, onFetch, onSync, onCreatePR, onOpenTerminal, onCheckoutBranch, onCreateBranch, onClose }: TopBarProps) { const [picker, setPicker] = useState(false) const [q, setQ] = useState('') const [createBranchOpen, setCreateBranchOpen] = useState(false) @@ -137,6 +138,7 @@ function TopBar({ workspace, branches, ahead, behind, lastFetch, fetching, remot + {onClose && }
@@ -310,11 +312,13 @@ interface ChangesBodyProps { onUnstageAll?: (paths: string[]) => void onDiscard?: (path: string) => void onOpenDiff?: (path: string, staged: boolean) => void + comparisonRef?: string } -function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, hasUnpushed, onOpenDiff }: ChangesBodyProps) { +function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, hasUnpushed, onOpenDiff, comparisonRef }: ChangesBodyProps) { const staged = changes.filter(c => c.staged) const unstaged = changes.filter(c => !c.staged) + const stageableUnstaged = unstaged.filter(c => c.stageable !== false) return ( <> @@ -352,8 +356,8 @@ function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, ha {unstaged.length > 0 && ( <>
- Changes · {unstaged.length} - + {comparisonRef ? `Changes vs ${comparisonRef}` : 'Changes'} · {unstaged.length} + {stageableUnstaged.length > 0 && }
{unstaged.map(f => ( @@ -369,11 +373,13 @@ function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, ha {f.add ? +{f.add} : null} {f.del ? −{f.del} : null} - + {f.stageable !== false && ( + + )}
))}
@@ -748,6 +754,8 @@ export interface GitSidebarProps extends GitSidebarHandlers { onOpenTerminal?: (path: string) => void pluginGitLenses?: RegisteredPluginGitLens[] onPluginGitLens?: (target: { pluginId: string; sidebarPanel?: string; tab?: string; command?: string }) => void + /** Mobile overlay close action. Omitted for the persistent desktop/sidebar page variants. */ + onClose?: () => void } export function GitSidebar({ @@ -766,6 +774,7 @@ export function GitSidebar({ onOpenTerminal, pluginGitLenses = [], onPluginGitLens, + onClose, }: GitSidebarProps) { const hasConflicts = (state.conflicts || []).length > 0 // A remote can exist after a partial publish while the branch was never pushed. @@ -809,7 +818,13 @@ export function GitSidebar({ }, [state.banner]) return ( -