Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 144 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,149 @@
- Package (examples): `pnpm build:mac`, `pnpm build:linux`.
- Lint: `pnpm lint`; Type-check: `pnpm typecheck` (runs per package).
- Tests (E2E): `pnpm test`, `pnpm test:ui`, CI configs in `playwright.ci*.config.ts`.
- Main unit tests (if added): `pnpm --filter main test`, coverage: `pnpm --filter main run test:coverage`.
- Main unit tests: `pnpm --filter main test` (Vitest), coverage: `pnpm --filter main run test:coverage`.
- Frontend unit tests: `pnpm --filter frontend test` (Vitest).
- New dependency → `pnpm run generate-notices` and commit `NOTICES`.

## Architecture Invariants

Read this section before writing code. Each item below is a place where the
obvious change is silently incomplete.

### Adding an IPC channel is a 7-file dance
IPC handlers do **not** call `ipcMain.handle` directly. They register against
`commandRegistry` and the file binds its channel list to `ipcMain` at the
bottom (`main/src/ipc/git.ts` is the canonical example). A channel is not
reachable until every step below is done; missing step 4 fails silently at
runtime, missing step 7 fails loudly in CI.

1. `main/src/ipc/<domain>.ts` — add the string to that file's
`DAEMON_*_CHANNELS` array **and** `commandRegistry.register(name, fn)`.
The array is what `commandRegistry.bindChannels(ipcMain, ...)` consumes.
2. `main/src/ipc/index.ts` — only if you added a new `register*Handlers` file.
3. `shared/types/daemon.ts` — `DAEMON_OWNED_CHANNEL_PREFIXES` /
`DAEMON_OWNED_EXACT_CHANNELS`. Skip only when an existing prefix
(`sessions:`, `panels:`, `projects:`, `terminal:`, …) already covers it.
4. `main/src/preload.ts` — this file **duplicates** the daemon-owned lists
inline, because a sandboxed preload cannot require local modules. If you
touched step 3 you MUST mirror it here, or the channel bypasses the remote
daemon bridge and only works locally.
5. `main/src/preload.ts` — add the method to the matching
`contextBridge.exposeInMainWorld('electronAPI', …)` group.
6. `frontend/src/types/electron.d.ts` + `frontend/src/utils/api.ts` — typed
signature and `API.*` wrapper.
7. `main/src/ipc/daemonRegistryBindings.test.ts` — the per-domain channel
arrays are asserted with `toEqual`. Adding a channel without updating this
file fails `pnpm --filter main test`.

Also update `tests/electronApiMock.ts` when a Playwright spec exercises the flow.

**Shortcut for panel-internal channels:** `window.electronAPI.invoke(channel, …)`
is a generic passthrough (see `TerminalPanel.tsx` calling `terminal:getState`).
It skips steps 5–6 — acceptable for internal plumbing, not for a public `API.*`
surface.

### `pnpm install` shadows the system `claude` binary
`main` depends on `@anthropic-ai/claude-code`, so `pnpm install` writes a
`claude` shim into `node_modules/.bin/`. Launching the app through a pnpm
script puts that directory at the front of `PATH`, and every agent terminal the
app spawns inherits it — so agents run the *bundled* Claude Code version rather
than the user's installed one. A version skew there surfaces as
`404 {"type":"not_found_error","message":"model: opus"}` or similar.

Nothing needs the shim: `claudeCodeManager` resolves the executable via
`findExecutableInPath('claude')` or the configured `claudeExecutablePath`, and
the package itself is only imported as a library. After a fresh install, delete
`node_modules/.bin/claude*` and `main/node_modules/.bin/claude*`.

### On Windows, dev builds write to the *production* data directory
`getAppDirectory()` auto-isolates to `~/.pane_dev` only when
`__CFBundleIdentifier === 'com.dcouple.pane'` — a **macOS-only** environment
variable. On Windows and Linux it falls through to `~/.pane`, so a dev run
shares the installed app's database and sockets. Always launch dev builds with
an explicit directory: `PANE_DIR=~/.pane_test pnpm dev`.

### Secondary terminal views must match the PTY's dimensions
Agent TUIs paint with absolute cursor positioning sized to the real PTY.
Replaying that byte stream into a terminal of a different width wraps every
line and each repaint pushes the viewport down — the console appears to scroll
without end. A read-only viewer must create its xterm at the PTY's exact
`cols`/`rows` (exposed on `TerminalPanelSnapshot`) and scale to fit with a CSS
transform. Never use `FitAddon` for a secondary view.

### Every git/shell read goes through `CommandRunner`
`CommandRunner` transparently wraps commands for WSL and remote hosts. Never
call `execSync`/`child_process` directly from a service. Obtain a runner from
`sessionManager.getProjectContext(sessionId).commandRunner` or
`getProjectContextByProjectId(projectId).commandRunner`. Both can return `null`
for orphaned sessions or projects without sessions — return an error result,
do not throw.

### `main/src/database/migrations/*.sql` is dead code
Nothing executes those files; `copy:assets` ships them to `dist` and they are
ignored. Real schema lives in exactly two places:
- `main/src/database/schema.sql` — executed statement-by-statement (split on
`;`) on every startup. Must be idempotent (`CREATE TABLE IF NOT EXISTS`), and
must never contain a `;` inside a comment or string literal. Prefer `--`
comments.
- `DatabaseService.runMigrations()` in `main/src/database/database.ts` —
hand-written TypeScript using `PRAGMA table_info(...)` feature detection.
Column additions, index creation and backfills go here.

For ad-hoc queries in a new service use `databaseService.getDb()` — the
sanctioned escape hatch (see `main/src/services/scrollbackRetention.ts`) —
rather than growing the ~5,000-line `database.ts` facade.

### Navigation has no router
`frontend/src/stores/navigationStore.ts` holds a single `activeView` enum. A
new full-page view means touching four places:
1. the `activeView` union — declared **twice** in that file (state interface and
`setActiveView` signature) — plus a `navigateToX()` action,
2. the render switch in `frontend/src/components/SessionView.tsx`, near the
`pane-chat` branch,
3. `frontend/src/components/Sidebar.tsx` — the **compact rail**,
4. `frontend/src/components/ProjectSessionList.tsx` — the **expanded tree**.

Sidebar entries live in two separate files; updating only one is the classic
miss. `PaneChatView.tsx` is the reference implementation of a full-page view.

### Adding a `ToolPanelType` is ~14 touchpoints
`PanelContainer` (lazy import + switch), `PanelTabBar` (`getPanelIcon`,
`typeOrder`, the create menu), `PanelTabStrip` (a **second, duplicated**
`getPanelIcon`), `PanelLoadingFallback`, the `PanelGroupView` keep-alive list,
`PANEL_CAPABILITIES` in `shared/types/panels.ts`, the `checkInitialized` switch
in `main/src/ipc/panels.ts`, and a `panelManager.ensureXxxPanel` helper. Panels
are keyed by `sessionId`; prefer a new `activeView` for anything that is not
scoped to a single session.

### Agents are terminal panels, not a panel type
An "agent" is a `terminal` panel whose `customState.isCliPanel === true`, with
`agentType: 'claude' | 'codex'`. `frontend/src/components/panels/cli/` is dead
code. "Is it running?" is answered by the agent-status pipeline, **not**
`Session.status`: `terminalPanelManager.pollAgentStatus` → `detectAgentState` →
`panel:agentStatus` event → `App.tsx` listener → `usePanelStore.setAgentStatus`.
Roll several panels up with `frontend/src/utils/agentStatus.ts`; read via
`frontend/src/hooks/useAgentStatus.ts`. States: `blocked | working | idle | unknown`.

### Terminals: WebGL and the shared texture atlas
xterm instances that share font and theme **share a WebGL texture atlas**;
clearing it from one corrupts the others (see the header comment in
`frontend/src/components/panels/TerminalPanel.tsx`). Never call
`clearTextureAtlas()`. Secondary or read-only terminal views must not load
`WebglAddon` at all — Chromium also caps live WebGL contexts at ~16.
`terminalPanelManager.setVisibility(panelId, visible, viewerId)` is refcounted
per viewer: always pass a distinct, prefixed `viewerId`, and always release it
on unmount.

### Lint rules that will fail your PR
- `@typescript-eslint/no-explicit-any` is an **error** in both packages. Parse
untrusted JSON as `unknown` and narrow with hand-written type guards.
- The frontend enforces ~13 `jsx-a11y` rules as errors, notably
`click-events-have-key-events` and `no-static-element-interactions`. Never put
`onClick` on a `<div>` or an SVG element — wrap the row in
`<button type="button">` and mark decorative SVG `aria-hidden="true"`.
- `no-console` is a warning in the frontend (`warn`/`error` allowed); console is
allowed in `main/`.

## Coding Style & Naming Conventions
- Use TypeScript throughout; follow ESLint configs in `frontend/eslint.config.js` and `main/eslint.config.js`.
Expand All @@ -36,6 +178,7 @@

## Agent Notes (for automation)
- Keep changes minimal and scoped; prefer small patches.
- Before proposing a new dependency, check whether the repo already solves it. There is no chart library and no router by deliberate choice; charts are hand-rolled SVG under `frontend/src/components/ui/charts/` and navigation is a single `activeView` enum.
- Update docs alongside code; do not alter build targets without discussion.
- Use repository scripts (pnpm) and keep formatting consistent with existing files.
- Always review the root `CLAUDE.md` before beginning any work.
Expand Down
40 changes: 39 additions & 1 deletion frontend/src/components/ProjectSessionList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef, useId } from 'react';
import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare } from 'lucide-react';
import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare, LayoutGrid } from 'lucide-react';
import { SessionDetailTooltip } from './SessionDetailTooltip';
import { useSessionStore } from '../stores/sessionStore';
import { useNavigationStore } from '../stores/navigationStore';
Expand Down Expand Up @@ -81,6 +81,7 @@ export function ProjectSessionList({
const navigateToPaneChat = useNavigationStore(s => s.navigateToPaneChat);
const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID);
const navigateToProject = useNavigationStore(s => s.navigateToProject);
const navigateToFleet = useNavigationStore(s => s.navigateToFleet);
const setSidebarNavigationScope = useNavigationStore(s => s.setSidebarNavigationScope);
// Expansion state lives in the navigation store so the always-mounted
// session hotkeys (useSessionNavigationHotkeys) see the same visible ordering
Expand Down Expand Up @@ -130,6 +131,12 @@ export function ProjectSessionList({

const projectById = useMemo(() => createProjectById(projects), [projects]);

/** Agents across every session that are waiting on the user — the fleet's badge. */
const blockedAgentCount = useMemo(
() => Object.values(agentStatusByPanel).filter(state => state === 'blocked').length,
[agentStatusByPanel]
);

const pinnedSessions = useMemo(() => {
return getPinnedSessions(sessions, projectById);
}, [sessions, projectById]);
Expand Down Expand Up @@ -314,6 +321,37 @@ export function ProjectSessionList({
<AgentStatusDot status={paneChatStatus} size="sm" className="ml-auto" />
</button>

<button
type="button"
data-testid="fleet-nav"
onClick={() => {
setSidebarNavigationScope('repositories');
navigateToFleet();
}}
className={cn(
SIDEBAR_ROW_BASE,
SIDEBAR_ROW_GAP,
SIDEBAR_ROW_PADDING,
'py-2 text-sm hover:bg-surface-hover hover:text-text-primary',
activeView === 'fleet'
? 'bg-surface-hover text-text-primary'
: 'text-text-secondary',
)}
>
<LayoutGrid className="w-4 h-4" />
<span>Agent Fleet</span>
{/* Agents waiting on an answer are worth seeing without opening the grid. */}
{blockedAgentCount > 0 && (
<span
className="ml-auto flex items-center gap-1 rounded-full bg-status-error/15 px-1.5 text-[10px] font-medium tabular-nums text-status-error"
title={`${blockedAgentCount} ${blockedAgentCount === 1 ? 'agent needs' : 'agents need'} input`}
>
<AgentStatusDot status="blocked" size="sm" />
{blockedAgentCount}
</span>
)}
</button>

{showRemoteDesktopLink && onRemoteDesktopClick && (
<Tooltip content={remoteDesktopTooltip} side="right" className="block w-full">
<button
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CommitMessageDialog } from './session/CommitMessageDialog';
import { FolderArchiveDialog } from './session/FolderArchiveDialog';
import { ConfirmDialog } from './ConfirmDialog';
import { ProjectView } from './ProjectView';
import { FleetView } from './fleet/FleetView';
import { API } from '../utils/api';
import { useResizable } from '../hooks/useResizable';
import { useResizableHeight } from '../hooks/useResizableHeight';
Expand Down Expand Up @@ -1651,6 +1652,11 @@ export const SessionView = memo(() => {

// Removed unused variables - now handled by panels

// Live grid of every agent pane — spans all projects.
if (activeView === 'fleet') {
return <FleetView />;
}

// Show project view if navigation is set to project
if (activeView === 'project' && activeProjectId) {
if (isProjectLoading || !projectData) {
Expand Down
30 changes: 29 additions & 1 deletion frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { CreateSessionDialog } from './CreateSessionDialog';
import { ProjectSessionList, ArchivedSessions } from './ProjectSessionList';
import { ArchiveProgress } from './ArchiveProgress';
import { ArrowUpDown, ChevronDown, ChevronRight, Cpu, FolderGit2, Home, Monitor, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Pin, Settings as SettingsIcon, Plus, RefreshCw, MessageSquare } from 'lucide-react';
import { ArrowUpDown, ChevronDown, ChevronRight, Cpu, FolderGit2, Home, Monitor, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Pin, Settings as SettingsIcon, Plus, RefreshCw, MessageSquare, LayoutGrid } from 'lucide-react';
import { SessionDetailTooltip } from './SessionDetailTooltip';
import { usePaneLogo } from '../hooks/usePaneLogo';
import { isMac } from '../utils/platformUtils';
Expand Down Expand Up @@ -380,10 +380,16 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
const navigateToProject = useNavigationStore((state) => state.navigateToProject);
const navigateToSessions = useNavigationStore((state) => state.navigateToSessions);
const navigateToPaneChat = useNavigationStore((state) => state.navigateToPaneChat);
const navigateToFleet = useNavigationStore((state) => state.navigateToFleet);
const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID);
const setSidebarNavigationScope = useNavigationStore((state) => state.setSidebarNavigationScope);
const agentStatusByPanel = usePanelStore((state) => state.agentStatus);
const agentPanelSessions = usePanelStore((state) => state.agentStatusSession);
/** Agents waiting on the user, anywhere — surfaced on the fleet rail button. */
const blockedAgentCount = useMemo(
() => Object.values(agentStatusByPanel).filter((state) => state === 'blocked').length,
[agentStatusByPanel]
);
const unviewedBySession = usePanelStore((state) => state.unviewedCompletedActivity);
useSessionNavigationHotkeys({ projects, sessionSortAscending });

Expand Down Expand Up @@ -493,6 +499,28 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
</button>
</Tooltip>

<Tooltip content="Agent Fleet" side="right">
<button
type="button"
data-testid="compact-fleet"
data-compact-rail-item
onClick={() => {
setSidebarNavigationScope('repositories');
navigateToFleet();
}}
aria-label={blockedAgentCount > 0
? `Agent Fleet — ${blockedAgentCount} waiting for input`
: 'Agent Fleet'}
className={`${COMPACT_RAIL_BUTTON} ${activeView === 'fleet' ? COMPACT_RAIL_ACTIVE : COMPACT_RAIL_IDLE}`}
>
<LayoutGrid className="h-4 w-4" />
{/* Same affordance as Pane Chat above: an agent is waiting. */}
{blockedAgentCount > 0 && (
<AgentStatusDot status="blocked" size="sm" className="absolute right-0 top-0" />
)}
</button>
</Tooltip>

{showRemoteDesktopLink && (
<Tooltip content={REMOTE_DESKTOP_TOOLTIP} side="right">
<button
Expand Down
Loading