From f3b75c3fdce062ee450a12d62eeb3b172e384287 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 17:49:11 +1000 Subject: [PATCH 01/13] fix(vscode): Workspace view detects dev servers from any target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Workspace view's Start/Stop row was scoped to *this workspace's* dev target — `devTarget.id` (`main` or a worktree basename). A dev started via the builder-row right-click context menu registers under `builder.id` from Tower's overview, which doesn't match any workspace target's id, so the row stayed stuck on "Start Dev Server" even with a dev actively running. Contradicts the single-dev-slot model in dev-shared.ts that enforces "one dev at a time across {main + all builders}". The slot was occupied; the UI just didn't reflect it. Three-way render now: - No dev running anywhere → Start Dev Server (for this workspace's target). Today's behaviour. - This workspace's dev is the one running → Stop Dev Server via codev.stopWorkspaceDev. Today's behaviour. - A *different* target's dev is running → Stop Dev Server via codev.stopWorktreeDev. Tooltip names the running target so the user sees what they're stopping. (Single-slot invariant means there's at most one to stop, so stopWorktreeDev's "kill all in the local registry" semantics are exactly "stop the slot.") The existing onDidChangeDevTerminals subscription already fires from context-menu-started devs (TerminalManager.openDevTerminal fires it unconditionally), so no event-plumbing changes are needed — only the check that consumes the event widened from per-target to per-slot. Mutually-exclusive row-count invariant preserved: still exactly one Start-or-Stop row, never both. --- packages/vscode/src/views/workspace.ts | 35 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index 2622966d0..30e00d5f1 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -82,14 +82,19 @@ export class WorkspaceProvider implements vscode.TreeDataProvider d.builderId === devTarget.id); + // Mutually exclusive: show Start when no dev is running anywhere, Stop + // when one is — regardless of which target started it. The single-slot + // model in dev-shared.ts means listDevTerminals() has at most one entry; + // reflect the slot's occupancy here so a dev started from a builder's + // right-click context menu is visible/stoppable from the Workspace view + // too. The visible control is itself the state indicator (play/stop + // model) — never both, no row-count jitter. + const allDevs = this.terminalManager.listDevTerminals(); + const targetDev = devTarget ? allDevs.find(d => d.builderId === devTarget.id) : undefined; + const otherDev = !targetDev ? allDevs[0] : undefined; // single-slot ⇒ at most one - if (targetDevRunning) { + if (targetDev) { + // This workspace's own dev is the running one. Today's Stop row. const stopDev = new vscode.TreeItem('Stop Dev Server'); stopDev.iconPath = new vscode.ThemeIcon('debug-stop'); stopDev.tooltip = `Stop the dev server for this workspace (target: ${devTarget!.id})`; @@ -99,7 +104,23 @@ export class WorkspaceProvider implements vscode.TreeDataProvider Date: Wed, 20 May 2026 18:03:10 +1000 Subject: [PATCH 02/13] feat(vscode): "Open Dev URL" workspace row(s) via worktree.devUrl(s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a one-click affordance to open the dev server's URL in the user's default browser, surfaced as a Workspace-view row below Start/Stop Dev Server. Schema (.codev/config.json): worktree.devUrl (legacy single) coexists with worktree.devUrls: [{ label, url }, ...] (multi, labeled). The canonical resolved shape is always a WorktreeDevUrl[]; a legacy single normalizes to [{ label: 'Open Dev URL', url }] so consumers don't branch. devUrls (if set) wins over devUrl. VSCode UX: one workspace-view row per resolved entry, visible whenever any URL is configured (independent of dev-PTY state). Row click and palette 'Codev: Open Dev URL' both route through vscode.env.openExternal — a real default browser gives DevTools, a real cookie jar, and OAuth that actually works, unlike VSCode's Simple Browser webview which has none of those (rationale recorded in #780). Palette invocation without an argument picks via QuickPick if multiple URLs are configured. Reads .codev/config.json directly from the extension (sync, small file) rather than adding a Tower endpoint just for one field. Existing 81 codev tests around the resolved-config shape updated for the schema change. --- .../src/agent-farm/__tests__/dev.test.ts | 5 +- .../__tests__/spawn-worktree.test.ts | 4 +- packages/codev/src/agent-farm/types.ts | 19 ++++ packages/codev/src/agent-farm/utils/config.ts | 31 ++++++ packages/vscode/package.json | 4 + packages/vscode/src/commands/open-dev-url.ts | 95 +++++++++++++++++++ packages/vscode/src/extension.ts | 3 + packages/vscode/src/views/workspace.ts | 20 ++++ 8 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 packages/vscode/src/commands/open-dev-url.ts diff --git a/packages/codev/src/agent-farm/__tests__/dev.test.ts b/packages/codev/src/agent-farm/__tests__/dev.test.ts index 76dbe0b28..9929f0f20 100644 --- a/packages/codev/src/agent-farm/__tests__/dev.test.ts +++ b/packages/codev/src/agent-farm/__tests__/dev.test.ts @@ -40,6 +40,7 @@ const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev', + devUrls: [], })); vi.mock('../utils/index.js', () => ({ getConfig: () => ({ workspaceRoot: '/proj' }), @@ -94,7 +95,7 @@ beforeEach(() => { findBuilderByIdMock.mockReturnValue(builder); listTerminalsMock.mockResolvedValue([]); killTerminalMock.mockResolvedValue(true); - getWorktreeConfigMock.mockReturnValue({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev' }); + getWorktreeConfigMock.mockReturnValue({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev', devUrls: [] }); }); // ─── Tests ────────────────────────────────────────────────────────────── @@ -110,7 +111,7 @@ describe('afx dev — validation', () => { }); it('errors when worktree.devCommand is unset', async () => { - getWorktreeConfigMock.mockReturnValueOnce({ symlinks: [], postSpawn: [], devCommand: null }); + getWorktreeConfigMock.mockReturnValueOnce({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] }); await expect(dev({ builderId: 'spir-42' })).rejects.toThrow(/No worktree\.devCommand configured/); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 790bdc8d6..228ad3c71 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -64,7 +64,7 @@ vi.mock('../../lib/forge.js', () => ({ // Mock the harness resolution to return claude harness by default import { CLAUDE_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js'; const getBuilderHarnessMock = vi.fn(() => CLAUDE_HARNESS); -const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null })); +const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] })); vi.mock('../utils/config.js', () => ({ getBuilderHarness: (...args: unknown[]) => getBuilderHarnessMock(...args), getWorktreeConfig: (...args: unknown[]) => getWorktreeConfigMock(...args), @@ -882,6 +882,7 @@ describe('spawn-worktree', () => { symlinks: ['.env.local', 'packages/*/.env'], postSpawn: [], devCommand: null, + devUrls: [], }); // Hardcoded section (.env / .codev/config.json) — both absent vi.mocked(existsSync) @@ -914,6 +915,7 @@ describe('spawn-worktree', () => { symlinks: ['.env.local'], postSpawn: [], devCommand: null, + devUrls: [], }); // Hardcoded section: both absent so no calls there vi.mocked(existsSync) diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index d68ef7e82..8c0204366 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -212,6 +212,25 @@ export interface UserConfig { * Example: 'pnpm dev'. */ devCommand?: string; + /** + * URL the dev server listens on, used by the VSCode extension's + * "Open Dev URL" workspace-view row to open the running app in the + * user's default browser. Legacy single-URL form — for multi-service + * setups prefer `devUrls`. Optional; the row is hidden when both + * fields are unset. Example: 'http://localhost:3000'. + */ + devUrl?: string; + /** + * Multiple labeled dev URLs — surfaced as one workspace-view row + * per entry (label = row text, url = what gets opened). Takes + * precedence over `devUrl` if both are set. The palette command + * `Codev: Open Dev URL` shows a QuickPick of these when invoked + * without a specific target. + * Example: + * [{ "label": "App", "url": "http://localhost:3000" }, + * { "label": "API", "url": "http://localhost:3001" }] + */ + devUrls?: Array<{ label: string; url: string }>; }; } diff --git a/packages/codev/src/agent-farm/utils/config.ts b/packages/codev/src/agent-farm/utils/config.ts index e6304f227..b22cb60a8 100644 --- a/packages/codev/src/agent-farm/utils/config.ts +++ b/packages/codev/src/agent-farm/utils/config.ts @@ -273,6 +273,12 @@ export function getBuilderHarness(workspaceRoot?: string): HarnessProvider { * Resolved view of the `worktree` config block with defaults applied. * Unset fields collapse to empty / null so callers don't have to branch. */ +/** One row in the VSCode "Open Dev URL" workspace surface. */ +export interface WorktreeDevUrl { + label: string; + url: string; +} + export interface ResolvedWorktreeConfig { /** Glob patterns to symlink from workspace root into each worktree. `[]` when unset. */ symlinks: string[]; @@ -280,6 +286,14 @@ export interface ResolvedWorktreeConfig { postSpawn: string[]; /** Command for `afx dev `. `null` when unset. */ devCommand: string | null; + /** + * Canonical resolved list of dev URLs for the VSCode "Open Dev URL" + * workspace-view row. Always an array — `[]` when neither `devUrl` + * nor `devUrls` is set in the user config. A legacy single + * `devUrl: ""` is normalized to `[{ label: "Open Dev URL", url }]` + * so consumers don't have to branch. + */ + devUrls: WorktreeDevUrl[]; } /** @@ -296,9 +310,26 @@ export function getWorktreeConfig(workspaceRoot?: string): ResolvedWorktreeConfi symlinks: w?.symlinks ?? [], postSpawn: w?.postSpawn ?? [], devCommand: w?.devCommand ?? null, + devUrls: resolveDevUrls(w), }; } +/** + * Resolution: `devUrls` (array) wins over `devUrl` (legacy single). + * Filters out malformed entries (non-string label/url, empty after trim). + */ +function resolveDevUrls(w: { devUrl?: string; devUrls?: Array<{ label?: string; url?: string }> } | undefined): WorktreeDevUrl[] { + if (Array.isArray(w?.devUrls)) { + return w.devUrls + .map(e => ({ label: typeof e?.label === 'string' ? e.label.trim() : '', url: typeof e?.url === 'string' ? e.url.trim() : '' })) + .filter(e => e.label && e.url); + } + if (typeof w?.devUrl === 'string' && w.devUrl.trim()) { + return [{ label: 'Open Dev URL', url: w.devUrl.trim() }]; + } + return []; +} + /** * Build configuration for the current project */ diff --git a/packages/vscode/package.json b/packages/vscode/package.json index db1984f07..4a934b792 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -148,6 +148,10 @@ "command": "codev.stopWorkspaceDev", "title": "Codev: Stop Dev Server (this workspace)" }, + { + "command": "codev.openDevUrl", + "title": "Codev: Open Dev URL" + }, { "command": "codev.pasteImage", "title": "Codev: Paste Image into Terminal", diff --git a/packages/vscode/src/commands/open-dev-url.ts b/packages/vscode/src/commands/open-dev-url.ts new file mode 100644 index 000000000..6e07ce487 --- /dev/null +++ b/packages/vscode/src/commands/open-dev-url.ts @@ -0,0 +1,95 @@ +/** + * Codev: Open Dev URL — open a URL configured under `worktree.devUrl` + * (legacy single) or `worktree.devUrls` (multi, labeled) in the user's + * default browser. Surfaced as one workspace-view row per configured + * URL (label = row text) plus a palette command. + * + * Why the default browser over VSCode's Simple Browser: DevTools / + * Console / Network are dev-loop essentials Simple Browser doesn't + * have, and a real browser sidesteps the third-party-cookie issues + * that come from loading the dev URL inside a `vscode-webview://` + * iframe. + */ + +import * as vscode from 'vscode'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ConnectionManager } from '../connection-manager.js'; + +export interface WorktreeDevUrl { + label: string; + url: string; +} + +/** + * Read the canonical resolved dev-URL list from the workspace's + * `.codev/config.json`. Mirrors the resolution rule in core's + * `getWorktreeConfig`: `devUrls` (array) wins over `devUrl` (legacy + * single); a single `devUrl` is normalized to `[{ label: "Open Dev URL", url }]`. + * Returns `[]` for missing/malformed config so callers don't have to + * branch. + */ +export function readWorktreeDevUrls(workspacePath: string | null): WorktreeDevUrl[] { + if (!workspacePath) { return []; } + try { + const raw = fs.readFileSync(path.join(workspacePath, '.codev', 'config.json'), 'utf-8'); + const parsed = JSON.parse(raw) as { + worktree?: { + devUrl?: unknown; + devUrls?: unknown; + }; + }; + const w = parsed.worktree; + if (Array.isArray(w?.devUrls)) { + return w.devUrls + .map((e): WorktreeDevUrl => { + const label = e && typeof (e as { label?: unknown }).label === 'string' + ? ((e as { label: string }).label).trim() : ''; + const url = e && typeof (e as { url?: unknown }).url === 'string' + ? ((e as { url: string }).url).trim() : ''; + return { label, url }; + }) + .filter(e => e.label && e.url); + } + if (typeof w?.devUrl === 'string' && w.devUrl.trim()) { + return [{ label: 'Open Dev URL', url: w.devUrl.trim() }]; + } + } catch { + // benign — fall through to [] + } + return []; +} + +export async function openDevUrl( + connectionManager: ConnectionManager, + urlArg?: string, +): Promise { + // Direct invocation: a row click passes its URL; just open it. + if (typeof urlArg === 'string' && urlArg.trim()) { + await vscode.env.openExternal(vscode.Uri.parse(urlArg)); + return; + } + + // Palette / arg-less invocation: resolve from config and route. + const workspacePath = connectionManager.getWorkspacePath(); + const devUrls = readWorktreeDevUrls(workspacePath); + + if (devUrls.length === 0) { + vscode.window.showWarningMessage( + 'Codev: `worktree.devUrl` / `worktree.devUrls` not configured in `.codev/config.json`', + ); + return; + } + if (devUrls.length === 1) { + await vscode.env.openExternal(vscode.Uri.parse(devUrls[0]!.url)); + return; + } + + const picked = await vscode.window.showQuickPick( + devUrls.map(d => ({ label: d.label, description: d.url, url: d.url })), + { placeHolder: 'Open which dev URL?' }, + ); + if (picked) { + await vscode.env.openExternal(vscode.Uri.parse(picked.url)); + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 3d693ce2a..313882dce 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -11,6 +11,7 @@ import { viewDiff, activateDiffView, diffUrisForChange } from './commands/view-d import { runWorktreeDev } from './commands/run-worktree-dev.js'; import { stopWorktreeDev } from './commands/stop-worktree-dev.js'; import { runWorkspaceDev, stopWorkspaceDev } from './commands/run-workspace-dev.js'; +import { openDevUrl } from './commands/open-dev-url.js'; import { pasteImage } from './commands/paste-image.js'; import { openWorktreeFolder } from './commands/open-worktree-folder.js'; import { runWorktreeSetup } from './commands/run-worktree-setup.js'; @@ -501,6 +502,8 @@ export async function activate(context: vscode.ExtensionContext) { runWorkspaceDev(connectionManager!, terminalManager!)), vscode.commands.registerCommand('codev.stopWorkspaceDev', () => stopWorkspaceDev(connectionManager!, terminalManager!)), + vscode.commands.registerCommand('codev.openDevUrl', (urlArg?: unknown) => + openDevUrl(connectionManager!, typeof urlArg === 'string' ? urlArg : undefined)), vscode.commands.registerCommand('codev.pasteImage', () => pasteImage(connectionManager!, terminalManager!)), vscode.commands.registerCommand('codev.refreshTeam', () => teamProvider.refresh()), diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index 30e00d5f1..df136cdc0 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -4,6 +4,7 @@ import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; import { getTowerAddress } from '../workspace-detector.js'; import { resolveWorkspaceDevTarget } from '../commands/dev-shared.js'; +import { readWorktreeDevUrls } from '../commands/open-dev-url.js'; /** * Workspace-level entry points: architect terminal, Tower web dashboard, @@ -134,6 +135,25 @@ export class WorkspaceProvider implements vscode.TreeDataProvider Date: Wed, 20 May 2026 18:13:56 +1000 Subject: [PATCH 03/13] feat(codev): support .codev/config.local.json per-engineer override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends a 5th layer to lib/config.ts:loadConfig. Reads /.codev/config.local.json (if present) and deep-merges it on top of the committed .codev/config.json. The existing 4-layer pipeline (defaults -> cache -> ~/.codev/config.json -> .codev/config.json) continues to apply below it. The use case the previous layers couldn't express: different engineers working on the same repo wanting different defaults (web dev defaults to 'pnpm dev', mobile dev to 'pnpm dev:mobile', backend to 'pnpm dev:api'). Layer 3 (~/.codev/config.json) spans every project on the machine, so it can't say 'in *this* repo only.' Layer 5 fixes that without inventing new machinery — deepMerge / readJsonFile are reused; a new resolveLocalConfigPath helper mirrors resolveProjectConfigPath so the layer-loading code stays uniform. agent-farm/utils/config.ts: loadUserConfig already calls loadConfig, so every worktree.* consumer (including the just-shipped devUrl/devUrls) automatically picks up local overrides with no further wiring. Recommended user action: add .codev/config.local.json to your project's .gitignore so per-engineer overrides aren't accidentally committed. Tests: 5 new cases in __tests__/config.test.ts — 3 covering loadConfig layer-5 semantics (local-only, local-overrides-project, missing-noop) and 2 covering resolveLocalConfigPath directly. All 26 config tests pass. --- packages/codev/src/__tests__/config.test.ts | 54 ++++++++++++++++++++- packages/codev/src/lib/config.ts | 33 ++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/codev/src/__tests__/config.test.ts b/packages/codev/src/__tests__/config.test.ts index 156df6fe1..a6bc864ee 100644 --- a/packages/codev/src/__tests__/config.test.ts +++ b/packages/codev/src/__tests__/config.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { deepMerge, loadConfig, resolveProjectConfigPath } from '../lib/config.js'; +import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath } from '../lib/config.js'; // Helpers let tmpDir: string; @@ -39,6 +39,12 @@ function writeGlobalConfig(config: Record) { fs.writeFileSync(path.join(globalCodevDir, 'config.json'), JSON.stringify(config, null, 2)); } +function writeLocalConfig(workspaceRoot: string, config: Record) { + const dir = path.join(workspaceRoot, '.codev'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.local.json'), JSON.stringify(config, null, 2)); +} + // ============================================================================= // deepMerge // ============================================================================= @@ -112,6 +118,22 @@ describe('resolveProjectConfigPath', () => { }); }); +// ============================================================================= +// resolveLocalConfigPath +// ============================================================================= + +describe('resolveLocalConfigPath', () => { + it('returns .codev/config.local.json path when it exists', () => { + writeLocalConfig(tmpDir, { shell: {} }); + const result = resolveLocalConfigPath(tmpDir); + expect(result).toBe(path.join(tmpDir, '.codev', 'config.local.json')); + }); + + it('returns null when no local config exists', () => { + expect(resolveLocalConfigPath(tmpDir)).toBeNull(); + }); +}); + // ============================================================================= // loadConfig // ============================================================================= @@ -201,4 +223,34 @@ describe('loadConfig', () => { const config = loadConfig(tmpDir); expect(config.porch?.checks?.lint).toEqual({ skip: true }); }); + + it('layer 5: .codev/config.local.json overrides defaults when project config absent', () => { + writeLocalConfig(tmpDir, { + shell: { architect: 'local-only-architect' }, + }); + const config = loadConfig(tmpDir); + expect(config.shell?.architect).toBe('local-only-architect'); + expect(config.shell?.builder).toBe('claude'); // default preserved + }); + + it('layer 5: local overrides project, non-overlapping project keys survive', () => { + writeProjectConfig(tmpDir, { + shell: { architect: 'project-architect', builder: 'project-builder' }, + }); + writeLocalConfig(tmpDir, { + shell: { architect: 'local-architect' }, + }); + const config = loadConfig(tmpDir); + expect(config.shell?.architect).toBe('local-architect'); // local wins + expect(config.shell?.builder).toBe('project-builder'); // project survives + }); + + it('layer 5: missing config.local.json is a no-op', () => { + writeProjectConfig(tmpDir, { + shell: { architect: 'project-architect' }, + }); + // no writeLocalConfig — file absent + const config = loadConfig(tmpDir); + expect(config.shell?.architect).toBe('project-architect'); + }); }); diff --git a/packages/codev/src/lib/config.ts b/packages/codev/src/lib/config.ts index 9c7925adb..0fe646c68 100644 --- a/packages/codev/src/lib/config.ts +++ b/packages/codev/src/lib/config.ts @@ -194,14 +194,34 @@ export function resolveProjectConfigPath(workspaceRoot: string): string | null { return null; } +/** + * Resolve the project-local override config path. + * + * Returns .codev/config.local.json if it exists, otherwise null. No + * legacy alias to consider — this layer is new. The local file is + * intended to be gitignored and per-engineer. + */ +export function resolveLocalConfigPath(workspaceRoot: string): string | null { + const localPath = resolve(workspaceRoot, '.codev', 'config.local.json'); + if (existsSync(localPath)) return localPath; + return null; +} + /** * Load the full merged config for a workspace. * * Layer order (lowest → highest priority): * 1. Hardcoded defaults * 2. /config.json (remote framework base config) - * 3. ~/.codev/config.json (global) - * 4. .codev/config.json (project) + * 3. ~/.codev/config.json (global, per-user, across all projects) + * 4. .codev/config.json (project, committed, shared with the team) + * 5. .codev/config.local.json (project, per-engineer, gitignored) + * + * Layer 5 is the place to put preferences that vary between engineers + * working on the same repo (e.g. different `worktree.devCommand` for + * web vs mobile roles) — Layer 3 spans every project so can't express + * "in *this* repo I want X." Add the file to your project's + * `.gitignore` so it's never accidentally committed. */ export function loadConfig(workspaceRoot: string): CodevConfig { let merged: CodevConfig = structuredClone(DEFAULT_CONFIG); @@ -232,6 +252,15 @@ export function loadConfig(workspaceRoot: string): CodevConfig { } } + // Layer 5: project-local override (gitignored, per-engineer). + const localPath = resolveLocalConfigPath(workspaceRoot); + if (localPath) { + const localConfig = readJsonFile(localPath); + if (localConfig) { + merged = deepMerge(merged as unknown as Record, localConfig) as CodevConfig; + } + } + // Validate custom harness definitions at load time if (merged.harness) { for (const [name, def] of Object.entries(merged.harness)) { From 09e3e31ae0999a8647e55f72777d93a503917d81 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 18:28:16 +1000 Subject: [PATCH 04/13] =?UTF-8?q?refactor(codev):=20tighten=20worktree.dev?= =?UTF-8?q?Urls=20schema=20=E2=80=94=20label=20required,=20no=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the legacy worktree.devUrl (legacy single-URL string) shipped one commit ago in bf7a2e7f, before any external user could depend on it. Sole form is now worktree.devUrls: Array<{ label, url }>; both fields are mandatory per schema. Entries missing either field are silently filtered out by the resolver and the VSCode-side reader. Why: with two coexisting shapes (devUrl vs devUrls), label-defaulting to "Open Dev URL" was a holdover from devUrl's literal semantic. Unlabeled multi-URL configs produced duplicate "Open Dev URL" rows distinguishable only by tooltip — confusing. Requiring label removes the ambiguity at the source: every row has a human-recognizable name picked by the user. Files: schema in types.ts; resolveDevUrls in utils/config.ts; readWorktreeDevUrls in vscode/commands/open-dev-url.ts. 107 tests still pass; tsc + lint clean on both packages. --- packages/codev/src/agent-farm/types.ts | 22 +++----- packages/codev/src/agent-farm/utils/config.ts | 22 ++++---- packages/vscode/src/commands/open-dev-url.ts | 51 ++++++++----------- 3 files changed, 38 insertions(+), 57 deletions(-) diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index 8c0204366..4edad89c3 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -213,22 +213,16 @@ export interface UserConfig { */ devCommand?: string; /** - * URL the dev server listens on, used by the VSCode extension's - * "Open Dev URL" workspace-view row to open the running app in the - * user's default browser. Legacy single-URL form — for multi-service - * setups prefer `devUrls`. Optional; the row is hidden when both - * fields are unset. Example: 'http://localhost:3000'. - */ - devUrl?: string; - /** - * Multiple labeled dev URLs — surfaced as one workspace-view row - * per entry (label = row text, url = what gets opened). Takes - * precedence over `devUrl` if both are set. The palette command - * `Codev: Open Dev URL` shows a QuickPick of these when invoked - * without a specific target. + * Dev URLs the running app(s) listen on — surfaced as one + * workspace-view row per entry in VSCode (`label` = row text, + * `url` = what opens in the default browser). The palette command + * `Codev: Open Dev URL` shows a QuickPick when invoked without a + * specific target. Both fields are required; entries missing + * either are silently filtered out. * Example: * [{ "label": "App", "url": "http://localhost:3000" }, - * { "label": "API", "url": "http://localhost:3001" }] + * { "label": "API", "url": "http://localhost:3001" }, + * { "label": "Admin", "url": "http://localhost:8080/admin" }] */ devUrls?: Array<{ label: string; url: string }>; }; diff --git a/packages/codev/src/agent-farm/utils/config.ts b/packages/codev/src/agent-farm/utils/config.ts index b22cb60a8..e8d4d2daf 100644 --- a/packages/codev/src/agent-farm/utils/config.ts +++ b/packages/codev/src/agent-farm/utils/config.ts @@ -315,19 +315,17 @@ export function getWorktreeConfig(workspaceRoot?: string): ResolvedWorktreeConfi } /** - * Resolution: `devUrls` (array) wins over `devUrl` (legacy single). - * Filters out malformed entries (non-string label/url, empty after trim). + * Filter malformed entries (missing/empty `label` or `url`). Both + * fields are mandatory by schema — no default-label fallback. */ -function resolveDevUrls(w: { devUrl?: string; devUrls?: Array<{ label?: string; url?: string }> } | undefined): WorktreeDevUrl[] { - if (Array.isArray(w?.devUrls)) { - return w.devUrls - .map(e => ({ label: typeof e?.label === 'string' ? e.label.trim() : '', url: typeof e?.url === 'string' ? e.url.trim() : '' })) - .filter(e => e.label && e.url); - } - if (typeof w?.devUrl === 'string' && w.devUrl.trim()) { - return [{ label: 'Open Dev URL', url: w.devUrl.trim() }]; - } - return []; +function resolveDevUrls(w: { devUrls?: Array<{ label?: string; url?: string }> } | undefined): WorktreeDevUrl[] { + if (!Array.isArray(w?.devUrls)) { return []; } + return w.devUrls + .map(e => ({ + label: typeof e?.label === 'string' ? e.label.trim() : '', + url: typeof e?.url === 'string' ? e.url.trim() : '', + })) + .filter(e => e.label && e.url); } /** diff --git a/packages/vscode/src/commands/open-dev-url.ts b/packages/vscode/src/commands/open-dev-url.ts index 6e07ce487..e66d01415 100644 --- a/packages/vscode/src/commands/open-dev-url.ts +++ b/packages/vscode/src/commands/open-dev-url.ts @@ -1,8 +1,9 @@ /** - * Codev: Open Dev URL — open a URL configured under `worktree.devUrl` - * (legacy single) or `worktree.devUrls` (multi, labeled) in the user's - * default browser. Surfaced as one workspace-view row per configured - * URL (label = row text) plus a palette command. + * Codev: Open Dev URL — open URLs configured under `worktree.devUrls` + * in the user's default browser. Surfaced as one workspace-view row + * per configured URL (label = row text), plus a palette command. + * Both `label` and `url` are mandatory per schema; entries missing + * either are silently filtered out. * * Why the default browser over VSCode's Simple Browser: DevTools / * Console / Network are dev-loop essentials Simple Browser doesn't @@ -23,37 +24,25 @@ export interface WorktreeDevUrl { /** * Read the canonical resolved dev-URL list from the workspace's - * `.codev/config.json`. Mirrors the resolution rule in core's - * `getWorktreeConfig`: `devUrls` (array) wins over `devUrl` (legacy - * single); a single `devUrl` is normalized to `[{ label: "Open Dev URL", url }]`. - * Returns `[]` for missing/malformed config so callers don't have to - * branch. + * `.codev/config.json`. Filters entries missing either `label` or + * `url` (both are mandatory per schema). Returns `[]` for + * missing/malformed config so callers don't have to branch. */ export function readWorktreeDevUrls(workspacePath: string | null): WorktreeDevUrl[] { if (!workspacePath) { return []; } try { const raw = fs.readFileSync(path.join(workspacePath, '.codev', 'config.json'), 'utf-8'); - const parsed = JSON.parse(raw) as { - worktree?: { - devUrl?: unknown; - devUrls?: unknown; - }; - }; - const w = parsed.worktree; - if (Array.isArray(w?.devUrls)) { - return w.devUrls - .map((e): WorktreeDevUrl => { - const label = e && typeof (e as { label?: unknown }).label === 'string' - ? ((e as { label: string }).label).trim() : ''; - const url = e && typeof (e as { url?: unknown }).url === 'string' - ? ((e as { url: string }).url).trim() : ''; - return { label, url }; - }) - .filter(e => e.label && e.url); - } - if (typeof w?.devUrl === 'string' && w.devUrl.trim()) { - return [{ label: 'Open Dev URL', url: w.devUrl.trim() }]; - } + const parsed = JSON.parse(raw) as { worktree?: { devUrls?: unknown } }; + const devUrls = parsed.worktree?.devUrls; + if (!Array.isArray(devUrls)) { return []; } + return devUrls + .map((e): WorktreeDevUrl => ({ + label: e && typeof (e as { label?: unknown }).label === 'string' + ? ((e as { label: string }).label).trim() : '', + url: e && typeof (e as { url?: unknown }).url === 'string' + ? ((e as { url: string }).url).trim() : '', + })) + .filter(e => e.label && e.url); } catch { // benign — fall through to [] } @@ -76,7 +65,7 @@ export async function openDevUrl( if (devUrls.length === 0) { vscode.window.showWarningMessage( - 'Codev: `worktree.devUrl` / `worktree.devUrls` not configured in `.codev/config.json`', + 'Codev: `worktree.devUrls` not configured in `.codev/config.json`', ); return; } From adc9b0da9149cbd81b871a373da3be1a656f86c5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 18:37:56 +1000 Subject: [PATCH 05/13] feat(vscode): live-refresh Workspace view on .codev/config(.local).json edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace view's rows are config-driven (Open Dev URL entries from worktree.devUrls, more to come) but until now the only refresh triggers were Tower connection state changes and dev-terminal lifecycle events. Editing .codev/config.json or .codev/config.local.json required a window reload to see the new rows. Adds a vscode.workspace.createFileSystemWatcher on .codev/config{,.local}.json under the active workspacePath. Any create/change/delete fires the provider's existing changeEmitter; getChildren() then re-reads the config on next render. The watching primitive is extracted to src/watch-codev-config.ts so future config-driven views can wire it in as a one-liner instead of duplicating the boilerplate. WorkspaceProvider's install is lazy because workspacePath isn't set at construction time (the Tower connection lands async). The first onStateChange that exposes a workspacePath installs the watcher and a flag pins it for the session. Disposables flow through context.subscriptions so cleanup is standard on extension deactivate. The pattern mirrors Tower's agent-farm/servers/tower-tunnel.ts:startConfigWatcher (which watches ~/.codev/cloud.json via node:fs.watch for OAuth credential changes) but this is the first vscode.workspace.createFileSystemWatcher in the extension — prior code reacted only to Tower / VSCode events, never to filesystem events. --- packages/vscode/src/extension.ts | 2 +- packages/vscode/src/views/workspace.ts | 28 +++++++++++++++++++++- packages/vscode/src/watch-codev-config.ts | 29 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 packages/vscode/src/watch-codev-config.ts diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 313882dce..a3bd2679e 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -265,7 +265,7 @@ export async function activate(context: vscode.ExtensionContext) { pullRequestsView, backlogView, recentlyClosedView, - vscode.window.registerTreeDataProvider('codev.workspace', new WorkspaceProvider(connectionManager, terminalManager!)), + vscode.window.registerTreeDataProvider('codev.workspace', new WorkspaceProvider(connectionManager, terminalManager!, context)), vscode.window.registerTreeDataProvider('codev.team', teamProvider), vscode.window.registerTreeDataProvider('codev.status', new StatusProvider(connectionManager)), ); diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index df136cdc0..b02a1e04e 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -5,6 +5,7 @@ import type { TerminalManager } from '../terminal-manager.js'; import { getTowerAddress } from '../workspace-detector.js'; import { resolveWorkspaceDevTarget } from '../commands/dev-shared.js'; import { readWorktreeDevUrls } from '../commands/open-dev-url.js'; +import { watchCodevConfig } from '../watch-codev-config.js'; /** * Workspace-level entry points: architect terminal, Tower web dashboard, @@ -18,12 +19,37 @@ export class WorkspaceProvider implements vscode.TreeDataProvider this.changeEmitter.fire()); + // The workspace view's rows are config-driven (Open Dev URL entries + // from worktree.devUrls, with more to come), so any edit to + // .codev/config.json (or its per-engineer .local.json sibling) should + // re-render. The watching primitive lives in src/watch-codev-config.ts; + // we just wire it up here. Lazy install: workspacePath isn't available + // at construction because the Tower connection lands async — attempt + // on every state change and pin via `configWatcherInstalled` the first + // time we succeed. + let configWatcherInstalled = false; + const installConfigWatcherIfReady = () => { + if (configWatcherInstalled) { return; } + const workspacePath = connectionManager.getWorkspacePath(); + if (!workspacePath) { return; } + configWatcherInstalled = true; + context.subscriptions.push( + watchCodevConfig(workspacePath, () => this.changeEmitter.fire()), + ); + }; + connectionManager.onStateChange(() => { + this.changeEmitter.fire(); + installConfigWatcherIfReady(); + }); // Re-render when the dev-terminal set changes (start/stop, a swap that // killed this workspace's dev, or cleanup) so the conditional "Stop Dev // Server" row reflects reality across every path. terminalManager.onDidChangeDevTerminals(() => this.changeEmitter.fire()); + // Eager attempt in case workspacePath is already set (fast cached + // connect path). + installConfigWatcherIfReady(); } getTreeItem(element: vscode.TreeItem): vscode.TreeItem { diff --git a/packages/vscode/src/watch-codev-config.ts b/packages/vscode/src/watch-codev-config.ts new file mode 100644 index 000000000..9edc6f4ff --- /dev/null +++ b/packages/vscode/src/watch-codev-config.ts @@ -0,0 +1,29 @@ +import * as vscode from 'vscode'; + +/** + * Fire `onChange` whenever `/.codev/config.json` or + * `.codev/config.local.json` is created, changed, or deleted. Returns a + * single `Disposable` bundling the watcher and its three subscriptions + * — push it onto `context.subscriptions` so it cleans up on extension + * deactivate. + * + * Mirrors Tower's own config watcher in + * `packages/codev/src/agent-farm/servers/tower-tunnel.ts:startConfigWatcher` + * (which watches `~/.codev/cloud.json` via `node:fs.watch`). This is the + * VSCode-runtime equivalent: `vscode.workspace.createFileSystemWatcher` + * gives us cross-platform consistency and standard Disposable lifecycle + * rather than the raw Node API. + */ +export function watchCodevConfig( + workspacePath: string, + onChange: () => void, +): vscode.Disposable { + const pattern = new vscode.RelativePattern(workspacePath, '.codev/config{,.local}.json'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + return vscode.Disposable.from( + watcher, + watcher.onDidCreate(onChange), + watcher.onDidChange(onChange), + watcher.onDidDelete(onChange), + ); +} From 4bf63ee2e9cfa2149ddaae3be44d98c2c35aec5d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 18:40:44 +1000 Subject: [PATCH 06/13] fix(vscode): config.local.json now triggers the workspace-view refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier glob pattern '.codev/config{,.local}.json' relied on an empty-string alternative inside the braces. VSCode's glob matcher inconsistently honors that — in practice events fired for config.json but not for config.local.json, so per-engineer override edits silently failed to refresh the view. Switching to two explicit non-empty alternatives '.codev/{config.json,config.local.json}' is unambiguous across glob implementations. Both files now consistently trigger the watcher's create/change/delete events. --- packages/vscode/src/watch-codev-config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/watch-codev-config.ts b/packages/vscode/src/watch-codev-config.ts index 9edc6f4ff..e0fdfb9fb 100644 --- a/packages/vscode/src/watch-codev-config.ts +++ b/packages/vscode/src/watch-codev-config.ts @@ -18,7 +18,15 @@ export function watchCodevConfig( workspacePath: string, onChange: () => void, ): vscode.Disposable { - const pattern = new vscode.RelativePattern(workspacePath, '.codev/config{,.local}.json'); + // Two explicit alternatives. Earlier draft used `.codev/config{,.local}.json` + // with an empty-string alternative inside the braces, which VSCode's glob + // matcher inconsistently honors — observed: matched `config.json` only, + // missed `config.local.json`. Listing both filenames explicitly is + // unambiguous across glob implementations. + const pattern = new vscode.RelativePattern( + workspacePath, + '.codev/{config.json,config.local.json}', + ); const watcher = vscode.workspace.createFileSystemWatcher(pattern); return vscode.Disposable.from( watcher, From a1d0c54449597b66d1d06b3213e0a44acb58627d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 18:41:07 +1000 Subject: [PATCH 07/13] feat(vscode): distinguish 'Open Dev URL' rows from 'Open Web Interface' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rows were rendering with the globe icon, making them visually identical in the Workspace view though they do different things — 'Open Web Interface' opens the Tower dashboard, 'Open Dev URL' opens the user's running app. Switch the Open Dev URL rows to 'link-external' (square + outgoing arrow) — VSCode's conventional 'opens outside the editor' glyph, which is also a more precise match for what vscode.env.openExternal does. 'Open Web Interface' keeps the more abstract 'globe' since the Tower dashboard is the closest thing to a 'project's web presence' the extension surfaces. --- packages/vscode/src/views/workspace.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index b02a1e04e..0b9124c76 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -169,7 +169,12 @@ export class WorkspaceProvider implements vscode.TreeDataProvider Date: Wed, 20 May 2026 19:36:20 +1000 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20Tower-mediated=20worktree=20confi?= =?UTF-8?q?g=20=E2=80=94=20endpoint=20+=20SSE=20+=20extension=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the architecture gap where the VSCode extension was parsing and merging .codev/config(.local).json itself instead of consuming the canonical resolved view from Tower. The merge logic now lives in one place (lib/config.ts:loadConfig); every client just asks the API. Tower (codev): - New GET /api/worktree-config route + handler. Returns the resolved ResolvedWorktreeConfig for the requested workspace (defaults / cache / global / project / project-local, deep-merged) by delegating to the existing getWorktreeConfig(workspaceRoot). - New worktree-config-watcher.ts: per-workspace node:fs.watch on .codev/{config.json,config.local.json}, debounced, fans out a 'worktree-config-updated' SSE event on change. Mirrors the existing tower-tunnel.ts:startConfigWatcher pattern for ~/.codev/cloud.json. The watcher is lazily installed by the route handler on first request and torn down by graceful shutdown. Types (codev-types): - ResolvedWorktreeConfig + WorktreeDevUrl promoted from a codev- internal interface to wire-contract types since they now cross HTTP. codev's utils/config.ts re-exports them so existing internal callers keep working. Core (codev-core): - TowerClient.getWorktreeConfig(workspacePath?) — same shape as getOverview/getIssue. VSCode extension: - open-dev-url.ts: removed fs.readFileSync + the inline two-file merge; readWorktreeDevUrls (sync) replaced with async loadWorktreeDevUrls(connectionManager) calling client.getWorktreeConfig. - workspace.ts: getChildren is async; constructor subscribes to connectionManager.onSSEEvent for 'worktree-config-updated'. Tower emits envelopes on the SSE 'data:' field with no 'event:' name (see builder-spawn-handler.ts:20 for the precedent comment), so the subscription JSON-parses the data and matches on envelope.type. Removed the context parameter — no longer needed. - watch-codev-config.ts: deleted. The extension never watches local filesystems anymore; Tower is the only thing that watches the config files and tells the extension via SSE. Why this matters: the extension is now tunnel-safe (Tower can be remote and config-change events flow over the existing SSE transport), there's exactly one watcher per workspace regardless of how many VSCode windows are open, and the merge semantics can't drift between client and server because the client doesn't merge anymore. The 'one place to merge config' invariant is finally true. Action-bound config consumers (devCommand, postSpawn, symlinks) were already live via Tower's per-invocation getWorktreeConfig() reads — this change adds the matching liveness for UI-bound consumers (devUrls today, anything we add to the workspace view later). 107 codev tests still pass. tsc + lint clean across all packages. --- .../src/agent-farm/servers/tower-routes.ts | 39 +++++++++ .../src/agent-farm/servers/tower-server.ts | 10 +++ .../servers/worktree-config-watcher.ts | 81 +++++++++++++++++++ packages/codev/src/agent-farm/utils/config.ts | 36 +++------ packages/core/src/tower-client.ts | 19 ++++- packages/types/src/api.ts | 30 +++++++ packages/types/src/index.ts | 2 + packages/vscode/src/commands/open-dev-url.ts | 52 +++++------- packages/vscode/src/extension.ts | 2 +- packages/vscode/src/views/workspace.ts | 54 ++++++------- packages/vscode/src/watch-codev-config.ts | 37 --------- 11 files changed, 235 insertions(+), 127 deletions(-) create mode 100644 packages/codev/src/agent-farm/servers/worktree-config-watcher.ts delete mode 100644 packages/vscode/src/watch-codev-config.ts diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index a61451c3f..cecc84a18 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -39,6 +39,8 @@ import { serveStaticFile, } from './tower-utils.js'; import { handleTunnelEndpoint } from './tower-tunnel.js'; +import { getWorktreeConfig } from '../utils/config.js'; +import { ensureWorktreeConfigWatcher } from './worktree-config-watcher.js'; import { hasTeam, loadTeamMembers, loadMessages, type TeamMember, type TeamMessage } from '../../lib/team.js'; import { fetchTeamGitHubData, type TeamMemberGitHubData } from '../../lib/team-github.js'; import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js'; @@ -147,6 +149,7 @@ const ROUTES: Record = { 'GET /api/status': (_req, res) => handleStatus(res), 'GET /api/overview': (_req, res, url, ctx) => handleOverview(res, url, undefined, ctx), 'GET /api/issue': (_req, res, url) => handleIssueView(res, url), + 'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url), 'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url), 'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx), 'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx), @@ -817,6 +820,42 @@ async function handleIssueView(res: http.ServerResponse, url: URL): Promise !p.includes('/.builders/')) || null; + } + if (!workspaceRoot) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing workspace' })); + return; + } + try { + const config = getWorktreeConfig(workspaceRoot); + ensureWorktreeConfigWatcher(workspaceRoot); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(config)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Failed to resolve worktree config: ${message}` })); + } +} + function handleOverviewRefresh(res: http.ServerResponse, ctx?: RouteContext): void { overviewCache.invalidate(); // Bugfix #388: Broadcast SSE event so all connected dashboard clients diff --git a/packages/codev/src/agent-farm/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 24166e08f..826b202d2 100644 --- a/packages/codev/src/agent-farm/servers/tower-server.ts +++ b/packages/codev/src/agent-farm/servers/tower-server.ts @@ -49,6 +49,7 @@ import { } from './tower-websocket.js'; import { handleRequest, startSendBuffer, stopSendBuffer } from './tower-routes.js'; import type { RouteContext } from './tower-routes.js'; +import { setWorktreeConfigNotifier, stopAllWorktreeConfigWatchers } from './worktree-config-watcher.js'; import { DEFAULT_TOWER_PORT } from '../lib/tower-client.js'; import { validateHost } from '../utils/server-utils.js'; @@ -159,6 +160,9 @@ async function gracefulShutdown(signal: string): Promise { // 6. Disconnect tunnel (Spec 0097 Phase 4 / Spec 0105 Phase 2) shutdownTunnel(); + // 6b. Close per-workspace .codev/config(.local).json watchers. + stopAllWorktreeConfigWatchers(); + // 7. Tear down instance module (Spec 0105 Phase 3) shutdownInstances(); @@ -319,6 +323,12 @@ const routeCtx: RouteContext = { }, }; +// Wire the broadcast function into the worktree config watcher so file +// edits to .codev/config{,.local}.json fan out as +// `worktree-config-updated` SSE events. The actual watcher is installed +// lazily by the /api/worktree-config route handler on first request. +setWorktreeConfigNotifier(broadcastNotification); + // ============================================================================ // Create server — delegates all HTTP handling to tower-routes.ts // ============================================================================ diff --git a/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts b/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts new file mode 100644 index 000000000..6d2620c4c --- /dev/null +++ b/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts @@ -0,0 +1,81 @@ +/** + * Per-workspace file watcher for `.codev/config.json` and + * `.codev/config.local.json`. Lazily installed by the + * `/api/worktree-config` route handler on first request, then persists + * for the Tower process lifetime. On each detected change it fans out + * a `worktree-config-updated` SSE event so subscribed clients (the + * VSCode extension, the dashboard) can refetch via the route and + * re-render. + * + * Pattern follows `tower-tunnel.ts:startConfigWatcher` (which watches + * `~/.codev/cloud.json` for OAuth credential changes) — `node:fs.watch` + * on the parent directory, filename filter, short debounce to coalesce + * the multiple events that fire per save. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type NotifyFn = (notification: { + type: string; + title: string; + body: string; + workspace?: string; +}) => void; + +const TARGET_FILES = new Set(['config.json', 'config.local.json']); +const DEBOUNCE_MS = 50; + +const watchers = new Map(); +const debounces = new Map(); +let notify: NotifyFn | undefined; + +/** + * Wire the broadcast function once at Tower startup. Subsequent calls + * to `ensureWorktreeConfigWatcher` will use this notifier when files + * change. + */ +export function setWorktreeConfigNotifier(fn: NotifyFn): void { + notify = fn; +} + +/** + * Lazily install (or no-op if already installed) a file watcher for + * `/.codev/{config.json,config.local.json}`. Safe to + * call on every route hit. + */ +export function ensureWorktreeConfigWatcher(workspacePath: string): void { + if (watchers.has(workspacePath)) { return; } + const dir = path.join(workspacePath, '.codev'); + try { + const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => { + if (!filename || !TARGET_FILES.has(filename)) { return; } + const prev = debounces.get(workspacePath); + if (prev) { clearTimeout(prev); } + debounces.set( + workspacePath, + setTimeout(() => { + debounces.delete(workspacePath); + notify?.({ + type: 'worktree-config-updated', + title: 'Worktree config changed', + body: JSON.stringify({ workspace: workspacePath }), + workspace: workspacePath, + }); + }, DEBOUNCE_MS), + ); + }); + watcher.on('error', () => { /* benign — dir may be removed mid-watch */ }); + watchers.set(workspacePath, watcher); + } catch { + // `.codev/` may not exist yet; the next ensure call will retry. + } +} + +/** Test / shutdown helper — close every watcher and clear pending debounces. */ +export function stopAllWorktreeConfigWatchers(): void { + for (const t of debounces.values()) { clearTimeout(t); } + debounces.clear(); + for (const w of watchers.values()) { try { w.close(); } catch { /* benign */ } } + watchers.clear(); +} diff --git a/packages/codev/src/agent-farm/utils/config.ts b/packages/codev/src/agent-farm/utils/config.ts index e8d4d2daf..c5ca82d22 100644 --- a/packages/codev/src/agent-farm/utils/config.ts +++ b/packages/codev/src/agent-farm/utils/config.ts @@ -11,6 +11,12 @@ import { getSkeletonDir } from '../../lib/skeleton.js'; import { loadConfig } from '../../lib/config.js'; import type { CodevConfig } from '../../lib/config.js'; import { resolveHarness, type HarnessProvider, type CustomHarnessConfig } from './harness.js'; +import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types'; + +// Re-export so existing internal callers that import the resolved types +// from this module keep working. The canonical home is now +// @cluesmith/codev-types (these cross HTTP via /api/worktree-config). +export type { ResolvedWorktreeConfig, WorktreeDevUrl }; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -269,32 +275,10 @@ export function getBuilderHarness(workspaceRoot?: string): HarnessProvider { ); } -/** - * Resolved view of the `worktree` config block with defaults applied. - * Unset fields collapse to empty / null so callers don't have to branch. - */ -/** One row in the VSCode "Open Dev URL" workspace surface. */ -export interface WorktreeDevUrl { - label: string; - url: string; -} - -export interface ResolvedWorktreeConfig { - /** Glob patterns to symlink from workspace root into each worktree. `[]` when unset. */ - symlinks: string[]; - /** Shell commands to run in each worktree after creation. `[]` when unset. */ - postSpawn: string[]; - /** Command for `afx dev `. `null` when unset. */ - devCommand: string | null; - /** - * Canonical resolved list of dev URLs for the VSCode "Open Dev URL" - * workspace-view row. Always an array — `[]` when neither `devUrl` - * nor `devUrls` is set in the user config. A legacy single - * `devUrl: ""` is normalized to `[{ label: "Open Dev URL", url }]` - * so consumers don't have to branch. - */ - devUrls: WorktreeDevUrl[]; -} +// ResolvedWorktreeConfig + WorktreeDevUrl now live in +// @cluesmith/codev-types (they cross HTTP via /api/worktree-config). +// Re-exported from this module at the top of the file so existing +// internal callers keep working. /** * Load the `worktree` block from .codev/config.json, applying defaults. diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index fdc438b10..34648a16e 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -7,7 +7,7 @@ * Extracted from packages/codev/src/agent-farm/lib/tower-client.ts */ -import type { DashboardState, OverviewData, IssueView } from '@cluesmith/codev-types'; +import type { DashboardState, OverviewData, IssueView, ResolvedWorktreeConfig } from '@cluesmith/codev-types'; import { DEFAULT_TOWER_PORT } from './constants.js'; import { ensureLocalKey } from './auth.js'; @@ -273,6 +273,23 @@ export class TowerClient { return result.ok ? result.data! : null; } + /** + * Fetch the canonical resolved worktree config (defaults / cache / + * global / project / project-local layers, deep-merged) from Tower's + * GET /api/worktree-config. The single source of truth for any client + * that needs to act on `.codev/config(.local).json` — e.g. the VSCode + * "Open Dev URL" surface — without parsing or merging the files + * locally. Tower lazily installs a directory watcher on first call; + * subsequent edits fan out a `worktree-config-updated` SSE event so + * subscribed clients refetch and re-render. Returns null on failure + * so callers can degrade. + */ + async getWorktreeConfig(workspacePath?: string): Promise { + const query = workspacePath ? `?workspace=${encodeURIComponent(workspacePath)}` : ''; + const result = await this.request(`/api/worktree-config${query}`); + return result.ok ? result.data! : null; + } + /** * Invalidate Tower's in-memory overview cache and broadcast an * `overview-changed` SSE event. Subscribed clients (VSCode sidebar, diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index c25145191..8a1307737 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -187,6 +187,36 @@ export interface OverviewData { errors?: { prs?: string; issues?: string }; } +// --- Worktree config (GET /api/worktree-config) --- + +/** One row in the VSCode "Open Dev URL" workspace surface. */ +export interface WorktreeDevUrl { + label: string; + url: string; +} + +/** + * Resolved view of the `worktree` config block with defaults applied + * across the loadConfig layer chain (defaults / cache / global / + * project / project-local). Always has populated fields — unset + * scalars collapse to null, unset collections to empty arrays — so + * consumers don't have to branch. + */ +export interface ResolvedWorktreeConfig { + /** Glob patterns to symlink from workspace root into each worktree. `[]` when unset. */ + symlinks: string[]; + /** Shell commands to run in each worktree after creation. `[]` when unset. */ + postSpawn: string[]; + /** Command for `afx dev `. `null` when unset. */ + devCommand: string | null; + /** + * Canonical resolved list of dev URLs for the VSCode "Open Dev URL" + * workspace surface. Always an array — `[]` when neither `devUrl` + * nor `devUrls` is set in the user config. + */ + devUrls: WorktreeDevUrl[]; +} + // --- Issue view (GET /api/issue) --- /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ef2b6a5ba..9bcdadf02 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -24,6 +24,8 @@ export { type OverviewRecentlyClosed, type OverviewData, type IssueView, + type WorktreeDevUrl, + type ResolvedWorktreeConfig, type TeamMemberGitHubData, type ReviewBlockingEntry, type TeamApiMember, diff --git a/packages/vscode/src/commands/open-dev-url.ts b/packages/vscode/src/commands/open-dev-url.ts index e66d01415..74b861192 100644 --- a/packages/vscode/src/commands/open-dev-url.ts +++ b/packages/vscode/src/commands/open-dev-url.ts @@ -3,7 +3,13 @@ * in the user's default browser. Surfaced as one workspace-view row * per configured URL (label = row text), plus a palette command. * Both `label` and `url` are mandatory per schema; entries missing - * either are silently filtered out. + * either are silently filtered out (by the resolver server-side). + * + * The resolved devUrls list comes from Tower's GET /api/worktree-config, + * which applies the full layered config merge (defaults / cache / + * global / project / project-local). The extension never parses + * `.codev/config.json` directly — Tower is the single source of truth + * for "what's configured" so the merge semantics can't drift. * * Why the default browser over VSCode's Simple Browser: DevTools / * Console / Network are dev-loop essentials Simple Browser doesn't @@ -13,40 +19,23 @@ */ import * as vscode from 'vscode'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; +import type { WorktreeDevUrl } from '@cluesmith/codev-types'; import type { ConnectionManager } from '../connection-manager.js'; -export interface WorktreeDevUrl { - label: string; - url: string; -} +export type { WorktreeDevUrl }; /** - * Read the canonical resolved dev-URL list from the workspace's - * `.codev/config.json`. Filters entries missing either `label` or - * `url` (both are mandatory per schema). Returns `[]` for - * missing/malformed config so callers don't have to branch. + * Fetch the canonical resolved dev-URL list for the active workspace + * from Tower. Returns `[]` when Tower is unreachable, the workspace + * isn't activated, or the config has no `devUrls` — callers don't + * have to branch. */ -export function readWorktreeDevUrls(workspacePath: string | null): WorktreeDevUrl[] { - if (!workspacePath) { return []; } - try { - const raw = fs.readFileSync(path.join(workspacePath, '.codev', 'config.json'), 'utf-8'); - const parsed = JSON.parse(raw) as { worktree?: { devUrls?: unknown } }; - const devUrls = parsed.worktree?.devUrls; - if (!Array.isArray(devUrls)) { return []; } - return devUrls - .map((e): WorktreeDevUrl => ({ - label: e && typeof (e as { label?: unknown }).label === 'string' - ? ((e as { label: string }).label).trim() : '', - url: e && typeof (e as { url?: unknown }).url === 'string' - ? ((e as { url: string }).url).trim() : '', - })) - .filter(e => e.label && e.url); - } catch { - // benign — fall through to [] - } - return []; +export async function loadWorktreeDevUrls(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { return []; } + const config = await client.getWorktreeConfig(workspacePath); + return config?.devUrls ?? []; } export async function openDevUrl( @@ -60,8 +49,7 @@ export async function openDevUrl( } // Palette / arg-less invocation: resolve from config and route. - const workspacePath = connectionManager.getWorkspacePath(); - const devUrls = readWorktreeDevUrls(workspacePath); + const devUrls = await loadWorktreeDevUrls(connectionManager); if (devUrls.length === 0) { vscode.window.showWarningMessage( diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index a3bd2679e..313882dce 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -265,7 +265,7 @@ export async function activate(context: vscode.ExtensionContext) { pullRequestsView, backlogView, recentlyClosedView, - vscode.window.registerTreeDataProvider('codev.workspace', new WorkspaceProvider(connectionManager, terminalManager!, context)), + vscode.window.registerTreeDataProvider('codev.workspace', new WorkspaceProvider(connectionManager, terminalManager!)), vscode.window.registerTreeDataProvider('codev.team', teamProvider), vscode.window.registerTreeDataProvider('codev.status', new StatusProvider(connectionManager)), ); diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index 0b9124c76..22c50069a 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -4,8 +4,7 @@ import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; import { getTowerAddress } from '../workspace-detector.js'; import { resolveWorkspaceDevTarget } from '../commands/dev-shared.js'; -import { readWorktreeDevUrls } from '../commands/open-dev-url.js'; -import { watchCodevConfig } from '../watch-codev-config.js'; +import { loadWorktreeDevUrls } from '../commands/open-dev-url.js'; /** * Workspace-level entry points: architect terminal, Tower web dashboard, @@ -19,44 +18,39 @@ export class WorkspaceProvider implements vscode.TreeDataProvider { - if (configWatcherInstalled) { return; } - const workspacePath = connectionManager.getWorkspacePath(); - if (!workspacePath) { return; } - configWatcherInstalled = true; - context.subscriptions.push( - watchCodevConfig(workspacePath, () => this.changeEmitter.fire()), - ); - }; - connectionManager.onStateChange(() => { - this.changeEmitter.fire(); - installConfigWatcherIfReady(); - }); + connectionManager.onStateChange(() => this.changeEmitter.fire()); // Re-render when the dev-terminal set changes (start/stop, a swap that // killed this workspace's dev, or cleanup) so the conditional "Stop Dev // Server" row reflects reality across every path. terminalManager.onDidChangeDevTerminals(() => this.changeEmitter.fire()); - // Eager attempt in case workspacePath is already set (fast cached - // connect path). - installConfigWatcherIfReady(); + // Tower fans out a `worktree-config-updated` SSE event whenever + // .codev/config(.local).json changes (server-side file watcher in + // worktree-config-watcher.ts). We re-render on that signal so the + // config-driven rows (Open Dev URL …) reflect edits live, without + // the extension ever needing to read or watch the file itself. + // + // Tower emits events as a JSON envelope on the SSE `data:` field + // with no `event:` name (see builder-spawn-handler.ts for the same + // gotcha), so the SSE-client-level `type` is always '' and the + // real type sits inside the envelope. + connectionManager.onSSEEvent(({ data }) => { + try { + const envelope = JSON.parse(data) as { type?: unknown }; + if (envelope.type === 'worktree-config-updated') { + this.changeEmitter.fire(); + } + } catch { + // benign — malformed envelope + } + }); } getTreeItem(element: vscode.TreeItem): vscode.TreeItem { return element; } - getChildren(): vscode.TreeItem[] { + async getChildren(): Promise { const items: vscode.TreeItem[] = []; const architect = new vscode.TreeItem('Open Architect'); @@ -166,7 +160,7 @@ export class WorkspaceProvider implements vscode.TreeDataProvider/.codev/config.json` or - * `.codev/config.local.json` is created, changed, or deleted. Returns a - * single `Disposable` bundling the watcher and its three subscriptions - * — push it onto `context.subscriptions` so it cleans up on extension - * deactivate. - * - * Mirrors Tower's own config watcher in - * `packages/codev/src/agent-farm/servers/tower-tunnel.ts:startConfigWatcher` - * (which watches `~/.codev/cloud.json` via `node:fs.watch`). This is the - * VSCode-runtime equivalent: `vscode.workspace.createFileSystemWatcher` - * gives us cross-platform consistency and standard Disposable lifecycle - * rather than the raw Node API. - */ -export function watchCodevConfig( - workspacePath: string, - onChange: () => void, -): vscode.Disposable { - // Two explicit alternatives. Earlier draft used `.codev/config{,.local}.json` - // with an empty-string alternative inside the braces, which VSCode's glob - // matcher inconsistently honors — observed: matched `config.json` only, - // missed `config.local.json`. Listing both filenames explicitly is - // unambiguous across glob implementations. - const pattern = new vscode.RelativePattern( - workspacePath, - '.codev/{config.json,config.local.json}', - ); - const watcher = vscode.workspace.createFileSystemWatcher(pattern); - return vscode.Disposable.from( - watcher, - watcher.onDidCreate(onChange), - watcher.onDidChange(onChange), - watcher.onDidDelete(onChange), - ); -} From 964ea4e9b81089976a98856e98f7bf8125bca96e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 19:44:33 +1000 Subject: [PATCH 09/13] feat(vscode): hide 'Start Dev Server' row when worktree.devCommand is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the row appeared unconditionally — clicking it with no devCommand configured produced a clear-but-still-pointless 'devCommand isn't set' toast from Tower. Now the row is gated on devCommand presence in the resolved config (Tower-merged across all 5 layers), so unconfigured workspaces simply don't show the row. Stop Dev Server is left alone — it stays visible whenever a dev is actually running, so the user can always kill a manually-started dev even if devCommand was later unset. Tooltip on the Start row now also previews the command itself (e.g. 'Run worktree.devCommand (`pnpm dev:local`) for this workspace…') so the user can see what'll fire before clicking. Along the way: extracted loadWorktreeConfig out of commands/open-dev-url.ts (which is now back to being command-specific) into a new top-level src/load-worktree-config.ts helper. The workspace view's getChildren does one fetch per render and reads both devCommand (for the Start gate) and devUrls (for the Open Dev URL rows) off the same response — no duplicate HTTP. --- packages/vscode/src/commands/open-dev-url.ts | 20 ++---------- packages/vscode/src/load-worktree-config.ts | 33 ++++++++++++++++++++ packages/vscode/src/views/workspace.ts | 31 +++++++++++++----- 3 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 packages/vscode/src/load-worktree-config.ts diff --git a/packages/vscode/src/commands/open-dev-url.ts b/packages/vscode/src/commands/open-dev-url.ts index 74b861192..7c07bcb82 100644 --- a/packages/vscode/src/commands/open-dev-url.ts +++ b/packages/vscode/src/commands/open-dev-url.ts @@ -19,24 +19,8 @@ */ import * as vscode from 'vscode'; -import type { WorktreeDevUrl } from '@cluesmith/codev-types'; import type { ConnectionManager } from '../connection-manager.js'; - -export type { WorktreeDevUrl }; - -/** - * Fetch the canonical resolved dev-URL list for the active workspace - * from Tower. Returns `[]` when Tower is unreachable, the workspace - * isn't activated, or the config has no `devUrls` — callers don't - * have to branch. - */ -export async function loadWorktreeDevUrls(connectionManager: ConnectionManager): Promise { - const client = connectionManager.getClient(); - const workspacePath = connectionManager.getWorkspacePath(); - if (!client || !workspacePath || connectionManager.getState() !== 'connected') { return []; } - const config = await client.getWorktreeConfig(workspacePath); - return config?.devUrls ?? []; -} +import { loadWorktreeConfig } from '../load-worktree-config.js'; export async function openDevUrl( connectionManager: ConnectionManager, @@ -49,7 +33,7 @@ export async function openDevUrl( } // Palette / arg-less invocation: resolve from config and route. - const devUrls = await loadWorktreeDevUrls(connectionManager); + const devUrls = (await loadWorktreeConfig(connectionManager))?.devUrls ?? []; if (devUrls.length === 0) { vscode.window.showWarningMessage( diff --git a/packages/vscode/src/load-worktree-config.ts b/packages/vscode/src/load-worktree-config.ts new file mode 100644 index 000000000..11dbbcd80 --- /dev/null +++ b/packages/vscode/src/load-worktree-config.ts @@ -0,0 +1,33 @@ +/** + * Thin client-side wrapper over Tower's `GET /api/worktree-config`. + * + * Returns the canonical `ResolvedWorktreeConfig` for the active + * workspace — Tower applies the full five-layer deep-merge (defaults + * / cache / global / project / project-local), so the extension + * never has to parse or merge `.codev/config(.local).json` itself. + * + * Lives at the top level of `src/` because multiple consumers need + * it (the Workspace tree view, the `Codev: Open Dev URL` command, + * any future config-driven UI). Past versions had this inlined in + * `commands/open-dev-url.ts`, but that file's job is one command; + * the resolved config is a workspace-wide concern. + */ + +import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types'; +import type { ConnectionManager } from './connection-manager.js'; + +export type { ResolvedWorktreeConfig, WorktreeDevUrl }; + +/** + * Returns `null` when Tower is unreachable or the workspace isn't + * activated. Callers extract whichever field they need (e.g. + * `(await loadWorktreeConfig(cm))?.devUrls ?? []`). + */ +export async function loadWorktreeConfig( + connectionManager: ConnectionManager, +): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { return null; } + return client.getWorktreeConfig(workspacePath); +} diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index 22c50069a..839c8f207 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -4,7 +4,7 @@ import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; import { getTowerAddress } from '../workspace-detector.js'; import { resolveWorkspaceDevTarget } from '../commands/dev-shared.js'; -import { loadWorktreeDevUrls } from '../commands/open-dev-url.js'; +import { loadWorktreeConfig } from '../load-worktree-config.js'; /** * Workspace-level entry points: architect terminal, Tower web dashboard, @@ -103,6 +103,13 @@ export class WorkspaceProvider implements vscode.TreeDataProvider d.builderId === devTarget.id) : undefined; const otherDev = !targetDev ? allDevs[0] : undefined; // single-slot ⇒ at most one @@ -140,13 +155,13 @@ export class WorkspaceProvider implements vscode.TreeDataProvider Date: Wed, 20 May 2026 20:01:23 +1000 Subject: [PATCH 10/13] feat(vscode): toggle changed-files view between list and tree (SCM-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each builder row in the Builders view now renders its expanded changed-files list as either a flat list (today's behaviour) or as a folder tree (default, matching VSCode's Source Control panel), with single-child folder chains compacted into one row — e.g. `packages/codev/src` displayed as a single folder, not three nested. Title-bar button toggles between the two; mirrors the accordion- toggle precedent (`codev.buildersAutoCollapse`). Setting `codev.buildersFileViewAsTree` (default true) backs the toggle and is honoured by the provider's `fileChildren` render branch. Tree-mode mechanics: - `file-path-tree.ts` builds a nested FilePathNode[] from the flat BuilderFileChange[]; compacts any folder whose lone child is also a folder. Folders sort before files at each level; case- insensitive alphabetical within each group. - `BuilderFolderTreeItem` is a collapsible row with the native Folder ThemeIcon and a stable id (`::folder::`), Expanded by default so the tree opens out on first render. The stable id persists user expand/collapse across the 60s overview-poll refreshes. - `BuildersProvider.getChildren` learns a folder-dispatch branch: expanding a folder row materialises its children (each either another folder or a leaf BuilderFileTreeItem). - `BuilderFileTreeItem` itself is unchanged — the leaf shape and click action are identical in both modes; only the grouping around the leaves differs. Wiring follows the accordion precedent at extension.ts: read setting → set context key → listen for config change → flip key + call `buildersProvider.refresh()` so the tree redraws in the new mode. A new public `refresh()` method on BuildersProvider exposes the change-emitter for this purpose. 10 new test cases in `file-path-tree.test.ts` cover empty input, flat files, deep single-file compaction, shared-prefix grouping, diverging branches (independent compaction), mixed folder/file ordering at one level, renames (oldPath survives), folder-with- single-file-child staying uncompacted, case-insensitive sorting, and a realistic monorepo PR shape end-to-end. vscode tests: 67 → 77 passing. --- packages/vscode/package.json | 25 +++ packages/vscode/src/extension.ts | 24 ++- .../vscode/src/test/file-path-tree.test.ts | 144 ++++++++++++++++++ .../src/views/builder-folder-tree-item.ts | 28 ++++ packages/vscode/src/views/builders.ts | 63 +++++++- packages/vscode/src/views/file-path-tree.ts | 122 +++++++++++++++ 6 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 packages/vscode/src/test/file-path-tree.test.ts create mode 100644 packages/vscode/src/views/builder-folder-tree-item.ts create mode 100644 packages/vscode/src/views/file-path-tree.ts diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 4a934b792..0b98b776d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -94,6 +94,16 @@ "title": "Codev: Turn Off Builders Auto-Collapse", "icon": "$(fold)" }, + { + "command": "codev.enableBuildersFileTreeMode", + "title": "Codev: View Changed Files as Tree", + "icon": "$(list-tree)" + }, + { + "command": "codev.disableBuildersFileTreeMode", + "title": "Codev: View Changed Files as List", + "icon": "$(list-flat)" + }, { "command": "codev.refreshTeam", "title": "Codev: Refresh Team", @@ -351,6 +361,16 @@ "when": "view == codev.builders && !codev.buildersAutoCollapse", "group": "navigation" }, + { + "command": "codev.disableBuildersFileTreeMode", + "when": "view == codev.builders && codev.buildersFileViewAsTree", + "group": "navigation" + }, + { + "command": "codev.enableBuildersFileTreeMode", + "when": "view == codev.builders && !codev.buildersFileViewAsTree", + "group": "navigation" + }, { "command": "codev.refreshOverview", "when": "view == codev.pullRequests", @@ -528,6 +548,11 @@ "type": "boolean", "default": true, "description": "Builders view accordion: expanding one builder auto-collapses the others, so only one builder's changed-files diff is open at a time." + }, + "codev.buildersFileViewAsTree": { + "type": "boolean", + "default": true, + "description": "Render a builder's changed-files list as a folder tree (with single-child folder chains compacted, like VSCode's Source Control panel) instead of a flat list. Toggleable via the Builders title-bar button." } } } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 313882dce..292c3e6d9 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -252,7 +252,8 @@ export async function activate(context: vscode.ExtensionContext) { // List views use createTreeView so their title can carry a live item // count; the rest stay on registerTreeDataProvider. - buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: new BuildersProvider(overviewCache, builderDiffCache) }); + const buildersProvider = new BuildersProvider(overviewCache, builderDiffCache); + buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: buildersProvider }); pullRequestsView = vscode.window.createTreeView('codev.pullRequests', { treeDataProvider: new PullRequestsProvider(overviewCache) }); backlogView = vscode.window.createTreeView('codev.backlog', { treeDataProvider: new BacklogProvider(overviewCache) }); recentlyClosedView = vscode.window.createTreeView('codev.recentlyClosed', { treeDataProvider: new RecentlyClosedProvider(overviewCache) }); @@ -308,6 +309,23 @@ export async function activate(context: vscode.ExtensionContext) { }), ); + // Builders file-view-as-tree: each builder's changed-files list renders + // as a folder tree (with single-child folder chains compacted, like + // VSCode SCM) when on, or as a flat list when off. Toggle via the + // header button / `codev.buildersFileViewAsTree`. Same mechanics as + // accordion above — read setting, mirror to context key, refresh + // provider on change so the tree redraws in the new mode. + const readFileViewAsTree = () => + vscode.workspace.getConfiguration('codev').get('buildersFileViewAsTree', true); + vscode.commands.executeCommand('setContext', 'codev.buildersFileViewAsTree', readFileViewAsTree()); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration('codev.buildersFileViewAsTree')) { return; } + vscode.commands.executeCommand('setContext', 'codev.buildersFileViewAsTree', readFileViewAsTree()); + buildersProvider.refresh(); + }), + ); + // Periodic overview refresh. VSCode has no timer-based refresh (event-only), // so an idle workspace never sees externally-merged PRs / new issues. Mirror // the dashboard's poll idiom: refresh on a cadence while the Codev sidebar is @@ -518,6 +536,10 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.getConfiguration('codev').update('buildersAutoCollapse', true, vscode.ConfigurationTarget.Global)), vscode.commands.registerCommand('codev.disableBuildersAutoCollapse', () => vscode.workspace.getConfiguration('codev').update('buildersAutoCollapse', false, vscode.ConfigurationTarget.Global)), + vscode.commands.registerCommand('codev.enableBuildersFileTreeMode', () => + vscode.workspace.getConfiguration('codev').update('buildersFileViewAsTree', true, vscode.ConfigurationTarget.Global)), + vscode.commands.registerCommand('codev.disableBuildersFileTreeMode', () => + vscode.workspace.getConfiguration('codev').update('buildersFileViewAsTree', false, vscode.ConfigurationTarget.Global)), vscode.commands.registerCommand('codev.reconnect', () => connectionManager?.reconnect()), vscode.commands.registerCommand('codev.connectTunnel', () => connectTunnel(connectionManager!)), vscode.commands.registerCommand('codev.disconnectTunnel', () => disconnectTunnel(connectionManager!)), diff --git a/packages/vscode/src/test/file-path-tree.test.ts b/packages/vscode/src/test/file-path-tree.test.ts new file mode 100644 index 000000000..9649381ec --- /dev/null +++ b/packages/vscode/src/test/file-path-tree.test.ts @@ -0,0 +1,144 @@ +import * as assert from 'assert'; +import { buildFilePathTree, type FilePathNode } from '../views/file-path-tree.js'; +import type { BuilderFileChange } from '../views/builder-diff-cache.js'; +import type { ChangeStatus } from '../commands/view-diff.js'; + +/** + * Tests for the path-tree builder + single-child folder compaction + * (mirroring VSCode SCM behaviour). + * + * Fixtures fake the BuilderFileChange shape just enough for tree + * grouping — only `plan.resourcePath` and `change.status` matter here; + * the tree builder doesn't inspect anything else. + */ + +function f(resourcePath: string, status: ChangeStatus = 'M'): BuilderFileChange { + return { + change: { status, path: resourcePath }, + plan: { resourcePath } as BuilderFileChange['plan'], + } as BuilderFileChange; +} + +/** Flatten a tree to `[name@fullPath, …]` for compact assertions. */ +function flatten(nodes: FilePathNode[]): string[] { + const out: string[] = []; + const visit = (n: FilePathNode, indent: string) => { + out.push(`${indent}${n.children ? '📁 ' : '📄 '}${n.name}@${n.fullPath}`); + if (n.children) { + for (const c of n.children) { visit(c, indent + ' '); } + } + }; + for (const n of nodes) { visit(n, ''); } + return out; +} + +suite('buildFilePathTree', () => { + test('empty input → empty output', () => { + assert.deepStrictEqual(buildFilePathTree([]), []); + }); + + test('flat-only files (no folders) → that many top-level leaves', () => { + const out = buildFilePathTree([f('a.ts'), f('b.ts'), f('README.md')]); + // All leaves, no children, sorted alphabetically (case-insensitive). + assert.deepStrictEqual(out.map(n => n.name), ['a.ts', 'b.ts', 'README.md']); + assert.ok(out.every(n => !n.children && n.file)); + }); + + test('single deep file is fully compacted into one folder node', () => { + // a/b/c/d.ts → one folder "a/b/c" containing one leaf "d.ts" + const out = buildFilePathTree([f('a/b/c/d.ts')]); + assert.strictEqual(out.length, 1); + const folder = out[0]!; + assert.strictEqual(folder.name, 'a/b/c'); + assert.strictEqual(folder.fullPath, 'a/b/c'); + assert.ok(folder.children); + assert.strictEqual(folder.children!.length, 1); + assert.strictEqual(folder.children![0]!.name, 'd.ts'); + assert.strictEqual(folder.children![0]!.fullPath, 'a/b/c/d.ts'); + assert.ok(folder.children![0]!.file); + }); + + test('multiple files sharing a prefix → shared folder, file children inside', () => { + const out = buildFilePathTree([ + f('packages/codev/src/x.ts'), + f('packages/codev/src/y.ts'), + ]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0]!.name, 'packages/codev/src'); + assert.deepStrictEqual( + out[0]!.children!.map(c => c.name), + ['x.ts', 'y.ts'], + ); + }); + + test('diverging branches keep their own compacted prefixes', () => { + // Two top-level packages with deep internals — each side compacts + // independently, no cross-branch merging. + const out = buildFilePathTree([ + f('packages/codev/src/a.ts'), + f('packages/vscode/src/b.ts'), + ]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0]!.name, 'packages'); + const pkgs = out[0]!.children!; + assert.strictEqual(pkgs.length, 2); + assert.deepStrictEqual(pkgs.map(p => p.name), ['codev/src', 'vscode/src']); + }); + + test('mixed files and folders at one level → folders sort before files', () => { + const out = buildFilePathTree([ + f('z.ts'), // top-level file + f('a/sub.ts'), // folder "a" + f('README.md'), // top-level file + ]); + // Order: folder ("a"), then files (README.md, z.ts) — sort within group. + assert.deepStrictEqual(out.map(n => n.name), ['a', 'README.md', 'z.ts']); + }); + + test('renames carry through to the leaf untouched', () => { + const renamed = f('new/path.ts', 'R'); + (renamed.change as { oldPath?: string }).oldPath = 'old/path.ts'; + const out = buildFilePathTree([renamed]); + // Walk to the leaf. + assert.strictEqual(out[0]!.name, 'new'); + const leaf = out[0]!.children![0]!; + assert.strictEqual(leaf.name, 'path.ts'); + assert.strictEqual(leaf.file!.change.status, 'R'); + assert.strictEqual((leaf.file!.change as { oldPath?: string }).oldPath, 'old/path.ts'); + }); + + test('folder containing exactly one *file* (not folder) child stays uncompacted', () => { + // VSCode SCM compacts single-child *folder* chains, but a folder + // whose lone child is a file stays as two rows — the file is the + // meaningful leaf. + const out = buildFilePathTree([f('docs/README.md')]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0]!.name, 'docs'); + assert.strictEqual(out[0]!.children!.length, 1); + assert.strictEqual(out[0]!.children![0]!.name, 'README.md'); + }); + + test('alphabetical sort is case-insensitive', () => { + const out = buildFilePathTree([f('Zoo.ts'), f('apple.ts'), f('Banana.ts')]); + assert.deepStrictEqual(out.map(n => n.name), ['apple.ts', 'Banana.ts', 'Zoo.ts']); + }); + + test('full-tree shape for a realistic monorepo PR', () => { + const out = buildFilePathTree([ + f('packages/codev/src/commands/consult/index.ts'), + f('packages/codev/src/commands/consult/types.ts'), + f('packages/vscode/src/views/builders.ts'), + f('CHANGELOG.md'), + ]); + const flat = flatten(out); + assert.deepStrictEqual(flat, [ + '📁 packages@packages', + ' 📁 codev/src/commands/consult@packages/codev/src/commands/consult', + ' 📄 index.ts@packages/codev/src/commands/consult/index.ts', + ' 📄 types.ts@packages/codev/src/commands/consult/types.ts', + ' 📁 vscode/src/views@packages/vscode/src/views', + ' 📄 builders.ts@packages/vscode/src/views/builders.ts', + '📄 CHANGELOG.md@CHANGELOG.md', + ]); + }); +}); diff --git a/packages/vscode/src/views/builder-folder-tree-item.ts b/packages/vscode/src/views/builder-folder-tree-item.ts new file mode 100644 index 000000000..3a5c270f1 --- /dev/null +++ b/packages/vscode/src/views/builder-folder-tree-item.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; +import type { FilePathNode } from './file-path-tree.js'; + +/** + * Intermediate folder row in tree-mode rendering of a builder's + * changed files. Carries everything the file-row constructor downstream + * needs (`worktreePath`, `baseRef`) so `BuildersProvider.getChildren` + * can materialise children without re-fetching from the diff cache. + * + * Expanded by default — mirrors VSCode SCM, where the file-tree opens + * out under each repository. A stable `id` (`::folder::`) + * lets VSCode persist user expand/collapse across the overview-poll + * refreshes; without it the tree would reset on every tick. + */ +export class BuilderFolderTreeItem extends vscode.TreeItem { + constructor( + public readonly builderId: string, + public readonly worktreePath: string, + public readonly baseRef: string, + public readonly node: FilePathNode, + ) { + super(node.name, vscode.TreeItemCollapsibleState.Expanded); + this.id = `${builderId}::folder::${node.fullPath}`; + this.iconPath = vscode.ThemeIcon.Folder; + this.contextValue = 'builder-file-folder'; + // No `command` — folder rows have no click action. + } +} diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index be1e562b9..743c18cd2 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -4,6 +4,8 @@ import { isIdleWaiting } from '@cluesmith/codev-core/builder-helpers'; import type { OverviewCache } from './overview-data.js'; import { BuilderTreeItem } from './builder-tree-item.js'; import { BuilderFileTreeItem } from './builder-file-tree-item.js'; +import { BuilderFolderTreeItem } from './builder-folder-tree-item.js'; +import { buildFilePathTree, type FilePathNode } from './file-path-tree.js'; import type { BuilderDiffCache } from './builder-diff-cache.js'; /** @@ -44,6 +46,15 @@ export class BuildersProvider implements vscode.TreeDataProvider this.changeEmitter.fire()); } + /** + * Force a re-render. Used by config-change listeners (e.g. the + * file-view-as-tree toggle) that aren't reflected in the overview + * cache but need the tree to redraw with the new setting applied. + */ + refresh(): void { + this.changeEmitter.fire(); + } + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { return element; } @@ -60,6 +71,12 @@ export class BuildersProvider implements vscode.TreeDataProvider + materialiseNode(element.builderId, element.worktreePath, element.baseRef, child), + ); + } // File rows are leaves. if (element instanceof BuilderFileTreeItem) { return []; @@ -119,7 +136,12 @@ export class BuildersProvider implements vscode.TreeDataProvider { const builder = this.cache.getData()?.builders.find(b => b.id === builderId); if (!builder?.worktreePath) { @@ -135,10 +157,49 @@ export class BuildersProvider implements vscode.TreeDataProvider + materialiseNode(builderId, builder.worktreePath, result.baseRef, node), + ); + } + // List mode (today's behaviour): flat, one row per changed file. return result.files.map( f => new BuilderFileTreeItem(builderId, builder.worktreePath, result.baseRef, f.change, f.plan), ); } + + /** Read the file-view-as-tree setting; falls back to the spec default. */ + private viewAsTree(): boolean { + return vscode.workspace + .getConfiguration('codev') + .get('buildersFileViewAsTree', true); + } +} + +/** + * Render one tree-mode node as either a folder row (if it has children) + * or a file row (if it carries a leaf). Folders carry the worktreePath + * + baseRef forward so the renderer can construct file children on + * subsequent expansion without re-fetching from the diff cache. + */ +function materialiseNode( + builderId: string, + worktreePath: string, + baseRef: string, + node: FilePathNode, +): vscode.TreeItem { + if (node.children) { + return new BuilderFolderTreeItem(builderId, worktreePath, baseRef, node); + } + // Leaf: must have a file (folder-without-children shouldn't be reachable + // from buildFilePathTree, but the type allows it — guard defensively). + if (!node.file) { + return placeholder(node.name); + } + return new BuilderFileTreeItem(builderId, worktreePath, baseRef, node.file.change, node.file.plan); } /** Non-clickable informational leaf (no worktree / no changes / error). */ diff --git a/packages/vscode/src/views/file-path-tree.ts b/packages/vscode/src/views/file-path-tree.ts new file mode 100644 index 000000000..b8266855b --- /dev/null +++ b/packages/vscode/src/views/file-path-tree.ts @@ -0,0 +1,122 @@ +/** + * Group a flat list of changed files into a nested folder tree, with + * VSCode SCM-style compaction: any folder with exactly one child folder + * gets merged into that child's path, so `packages/codev/src` renders + * as a single row instead of three nested folders. + * + * Pure path manipulation — no VSCode dependency, easy to unit-test. + * The leaf node carries the original `BuilderFileChange` verbatim so + * the renderer can build `BuilderFileTreeItem` instances unchanged + * (including renames, status badges, etc.). + */ + +import type { BuilderFileChange } from './builder-diff-cache.js'; + +/** + * One node in the file-path tree. Folder nodes have `children`; leaf + * (file) nodes have `file`. The two are mutually exclusive in practice + * but the shape doesn't enforce it — the renderer branches on which + * field is present. + * + * `name` is the *display* label (may be compacted, e.g. + * `packages/codev/src`). `fullPath` is the canonical relative path + * from the worktree root; used as a stable id so VSCode persists + * folder-expansion state across overview-poll refreshes. + */ +export interface FilePathNode { + name: string; + fullPath: string; + file?: BuilderFileChange; + children?: FilePathNode[]; +} + +/** + * Build a folder-tree representation of the given files, then apply + * single-child-folder compaction. Returns the top-level nodes + * (folders or root-level files), sorted folders-first / alphabetical + * within each group. + */ +export function buildFilePathTree(files: BuilderFileChange[]): FilePathNode[] { + // Walk each file's path into a mutable nested structure. Folder nodes + // get an internal `_kids` map keyed by segment name so we can merge + // siblings as we go; the final shape drops it in favour of + // `children: FilePathNode[]`. + interface Mutable { + name: string; + fullPath: string; + file?: BuilderFileChange; + _kids?: Map; + } + + const root: Mutable = { name: '', fullPath: '', _kids: new Map() }; + for (const f of files) { + const segments = f.plan.resourcePath.split('/').filter(s => s.length > 0); + let cursor = root; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const isLeaf = i === segments.length - 1; + const parentPath = cursor.fullPath; + const childPath = parentPath ? `${parentPath}/${seg}` : seg; + if (!cursor._kids) { cursor._kids = new Map(); } + let next = cursor._kids.get(seg); + if (!next) { + next = { name: seg, fullPath: childPath }; + cursor._kids.set(seg, next); + } + if (isLeaf) { + next.file = f; + } + cursor = next; + } + } + + // Convert the mutable structure to FilePathNode, sort, and compact. + const convert = (m: Mutable): FilePathNode => { + const kids = m._kids ? [...m._kids.values()].map(convert) : undefined; + return { + name: m.name, + fullPath: m.fullPath, + ...(m.file ? { file: m.file } : {}), + ...(kids && kids.length > 0 ? { children: kids } : {}), + }; + }; + const compactAndSort = (node: FilePathNode): FilePathNode => { + if (!node.children) { return node; } + // Single-child compaction: collapse only when the lone child is + // itself a folder. A folder with a single *file* child stays as + // two rows — that matches VSCode SCM (the file is the meaningful + // leaf and shouldn't merge into its parent). + let compacted: FilePathNode = node; + while ( + compacted.children && + compacted.children.length === 1 && + compacted.children[0]!.children !== undefined + ) { + const only = compacted.children[0]!; + compacted = { + name: `${compacted.name}/${only.name}`, + fullPath: only.fullPath, + children: only.children, + }; + } + // Recurse, then sort: folders before files, alphabetical within. + compacted = { + ...compacted, + children: compacted.children!.map(compactAndSort).sort(compareNodes), + }; + return compacted; + }; + + const topLevel = root._kids + ? [...root._kids.values()].map(convert).map(compactAndSort) + : []; + return topLevel.sort(compareNodes); +} + +/** Folders before files; alphabetical (case-insensitive) within each group. */ +function compareNodes(a: FilePathNode, b: FilePathNode): number { + const aIsFolder = a.children !== undefined; + const bIsFolder = b.children !== undefined; + if (aIsFolder !== bIsFolder) { return aIsFolder ? -1 : 1; } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} From f49316e93d07f00bae1036bfefd3e64461076d2e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 20:02:48 +1000 Subject: [PATCH 11/13] fix(vscode): resolve devCommand through Tower so .codev/config.local.json is honored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-launch path (codev.runWorkspaceDev / codev.runWorktreeDev) was reading worktree.devCommand directly from .codev/config.json via fs.readFileSync, bypassing the 5-layer config merge. Result: if the devCommand was set in .codev/config.local.json (the per-engineer override layer) but not in the committed config.json, clicking 'Start Dev Server' surfaced 'Configure worktree.devCommand …' even though the resolved config had it. Switches dev-shared.ts to loadWorktreeConfig(), which calls Tower's GET /api/worktree-config — the same source the Workspace tree view uses for the Start row's visibility/tooltip. The launch path and the row visibility now agree on what 'configured' means. --- packages/vscode/src/commands/dev-shared.ts | 25 ++++++++-------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/vscode/src/commands/dev-shared.ts b/packages/vscode/src/commands/dev-shared.ts index 35d4dca08..6c2cbb0c6 100644 --- a/packages/vscode/src/commands/dev-shared.ts +++ b/packages/vscode/src/commands/dev-shared.ts @@ -14,10 +14,10 @@ */ import * as vscode from 'vscode'; -import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; +import { loadWorktreeConfig } from '../load-worktree-config.js'; export const KILL_WAIT_TIMEOUT_MS = 7000; export const KILL_POLL_INTERVAL_MS = 200; @@ -50,19 +50,6 @@ export function resolveWorkspaceDevTarget(workspacePath: string): DevTarget { return { id: 'main', cwd: workspacePath, name: 'Workspace' }; } -/** Read `worktree.devCommand` from `.codev/config.json`; null if unset/unreadable. */ -export async function readDevCommand(workspacePath: string): Promise { - const configPath = path.join(workspacePath, '.codev', 'config.json'); - try { - const raw = await readFile(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as { worktree?: { devCommand?: unknown } }; - const cmd = parsed.worktree?.devCommand; - return typeof cmd === 'string' && cmd.length > 0 ? cmd : null; - } catch { - return null; - } -} - /** Poll `listTerminals` until the killed terminal disappears (or timeout). */ export async function waitForTerminalGone( client: NonNullable>, @@ -95,10 +82,16 @@ export async function startDevForTarget( return; } - const devCommand = await readDevCommand(workspacePath); + // Resolve the dev command through Tower so the full 5-layer config merge + // (defaults / cache / global / project / project-local) applies — including + // `.codev/config.local.json`, which is the per-engineer override layer. + // Reading `.codev/config.json` directly would miss the local override. + const worktreeConfig = await loadWorktreeConfig(connectionManager); + const devCommand = worktreeConfig?.devCommand ?? null; if (!devCommand) { vscode.window.showErrorMessage( - 'Codev: Configure worktree.devCommand in .codev/config.json to use this action. ' + + 'Codev: Configure worktree.devCommand in .codev/config.json ' + + '(or .codev/config.local.json) to use this action. ' + 'See "Runnable Worktrees" in CLAUDE.md for stack-specific recipes.', ); return; From ace88934d5434818cce29de5e7dcd39f46266930 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 20:08:55 +1000 Subject: [PATCH 12/13] fix(vscode): auto-collapse re-expansion recurses through the file tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the accordion fires (a user expanded a different builder, so the current one collapsed via `workbench.actions.treeView.codev. builders.collapseAll`), the subsequent reveal at the new builder used `expand: true` — which expands only the target row, not its descendants. VSCode had just persisted "collapsed" against each folder's stable id during the collapseAll step, so re-expanding the builder restored the persisted state for every child level and the file tree appeared closed. VSCode's TreeView.reveal clamps `expand` at 3 in mainThreadTreeViews.ts (`Math.min(expand, 3)`); `true` maps to 1. Switching `expand: true` → `expand: 3` asks for the API ceiling — the re-expansion cascades through the descendant folders, restoring the default "folders expanded" look that fresh-rendered BuilderFolderTreeItems carry via TreeItemCollapsibleState.Expanded. With single-child folder compaction (from the SCM-style toggle that just landed), the practical worktree depth fits comfortably in 3 levels — e.g. `packages/codev/src/commands/consult/index.ts` compacts to builder → `packages` → `codev/src/commands/consult` → file leaves, exactly 3 levels below the builder row. Trees with 4+ distinct branching folder levels after compaction would have their deepest level remain collapsed (VSCode's hard cap, not addressable via a single reveal call); rare in the codebases this surface targets, and the user can still click into them manually. If it ever bites, the workaround is iterating reveal() over the builder's descendant folders to compound 3-level windows — left as follow-up work. --- packages/vscode/src/extension.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 292c3e6d9..60b3bea4e 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -297,7 +297,14 @@ export async function activate(context: vscode.ExtensionContext) { reconciling = true; try { await vscode.commands.executeCommand('workbench.actions.treeView.codev.builders.collapseAll'); - await buildersView!.reveal(e.element, { expand: true, select: false, focus: false }); + // `expand: 3` (the VSCode max) recursively expands the builder + // and its file-tree descendants — so re-expanding after the + // accordion's collapseAll restores the default expanded-folder + // look instead of leaving every folder collapsed. Without this, + // collapseAll persists "collapsed" against each folder's stable + // id, and the next reveal honours that persisted state for + // every level below the builder row itself. + await buildersView!.reveal(e.element, { expand: 3, select: false, focus: false }); } finally { reconciling = false; } From 76be5f9e9c12fc339ad231187fd5af600f36baa5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 20 May 2026 20:15:06 +1000 Subject: [PATCH 13/13] docs(vscode): [Unreleased] section for the dev-workflow batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four user-facing features and four bug fixes covering the 12-commit batch on this branch: What's new — `worktree.devUrls` config-driven "Open Dev URL" rows in the Workspace view; per-engineer `.codev/config.local.json` overrides layered on top of the shared config; live-refresh of the sidebar on config edits (driven via Tower so multiple windows stay in sync); title-bar toggle on the Builders view to switch the changed-files list between a folder tree (default, SCM-style) and a flat list. Bug fixes — Workspace view now detects dev servers started from the builder right-click context menu (not just its own row); the "Start Dev Server" row is hidden on workspaces without a `worktree.devCommand`; `config.local.json` overrides now apply when running the dev command, not only to the sidebar display; re-expanding a builder after the accordion's auto-collapse now restores its folder tree, not just the top row. --- packages/vscode/CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 28dc3dde1..3e41a7577 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -2,7 +2,23 @@ What's changed in the Codev VS Code extension, version by version, written for the developers who use it. -## [3.0.8] - 2026-05-19 +## [Unreleased] + +### What's new + +- **"Open Dev URL" rows in the Workspace view.** Add an array of `{ label, url }` entries under `worktree.devUrls` in `.codev/config.json` to surface dev/staging/preview links as one-click rows in the Workspace view. Clicking opens the URL in your **default browser**. Distinct from "Open Web Interface", which always points at Tower's dashboard. +- **Per-engineer config overrides via `.codev/config.local.json`.** A gitignored sibling to `.codev/config.json` that layers your personal overrides on top of the shared project config — your local staging URLs, tunnel hostnames, etc. stay out of the file everyone else commits. +- **Workspace view live-refreshes on config edits.** Edit `.codev/config.json` or `.codev/config.local.json` and every open VSCode window's sidebar re-renders immediately; no reload needed. Driven via Tower so multiple windows stay in sync. +- **Changed-files view toggles between tree and list.** A new title-bar button on the Builders view switches a builder's expanded file list between a folder tree and a flat list. Setting: `codev.buildersFileViewAsTree`. + +### Bug fixes + +- **Workspace view detects dev servers started from any source.** Starting a dev from a builder row's right-click context menu now correctly flips the Workspace view's row to "Stop Dev Server" — previously the row stayed stuck on "Start Dev Server" because the check was scoped to this workspace's own target. +- **"Start Dev Server" row is hidden when `worktree.devCommand` isn't configured.** No more click → no-op / error on workspaces that don't define a dev command. +- **`.codev/config.local.json` overrides also apply when actually running the dev command.** Previously the override changed only what the sidebar displayed; the command Tower ran still came from the shared config. Now both honour the layered config. +- **Re-expanding a builder restores its folder tree.** After accordion auto-collapse (clicking a different builder), re-expanding the first builder now re-expands its folders too — not just the top builder row. + +## [3.0.8] - 2026-05-20 ### What's new