diff --git a/BOOCODER.md b/BOOCODER.md index 96432a7..960e094 100644 --- a/BOOCODER.md +++ b/BOOCODER.md @@ -37,3 +37,81 @@ Every file modification queues in `pending_changes` before touching disk. The us - Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts. - Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence. - When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation. + +## Provider lifecycle (v2.3) + +BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*. + +### Config file: `data/coder-providers.json` + +Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **tracked in git** via a `.gitignore` exception (the rest of `data/*` is ignored). A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup. + +```json +{ + "providers": { + "goose": { "enabled": false }, + "amp-acp": { + "extends": "acp", + "label": "Amp", + "description": "ACP wrapper for Amp", + "command": ["amp-acp"], + "enabled": true + } + } +} +``` + +Per-provider override fields (all optional): + +| Field | Meaning | +|-------|---------| +| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it | +| `label` | Display name (required for custom) | +| `description` | Sub-label shown in the picker / settings | +| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) | +| `env` | Extra env vars merged into the spawn | +| `enabled` | Default `true`; `false` hides it from the composer | +| `order` | UI sort key | +| `models` / `additionalModels` | Replace / merge onto the discovered model list | + +A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default). + +### Refresh contract + +The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes. + +### Enable / disable + +Two ways: +- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.) +- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`. + +A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable. + +### Adding a custom ACP provider + +- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider. +- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`. + +Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker. + +### Known limitation — subset refresh + +`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped. + +### Deploy + smoke + +Two deploy targets: +- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` +- **Web UI (container):** `docker compose up --build -d boocode` + +Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`. + +Smoke (via Tailscale): + +```bash +curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider +curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy +# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab +# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed) +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index cc47932..fd8ac50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.5.13-provider-lifecycle-phase5 — 2026-05-29 + +Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred. + ## v2.5.12-provider-lifecycle-phase4 — 2026-05-29 Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`. diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx index a342441..2dab033 100644 --- a/apps/web/src/components/AgentComposerBar.tsx +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; +import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons'; import { api } from '@/api/client'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; @@ -176,8 +176,12 @@ interface Props { export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) { const allEntries = useProviderSnapshot(projectPath); + // 5.5 — the composer picker only offers ENABLED providers that are ready (or + // still loading). Disabled (enabled:false) and unavailable/error providers are + // hidden here and managed in Settings → Providers. Native boocode is always + // enabled+ready, so it always appears. const entries = useMemo( - () => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null, + () => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null, [allEntries], ); const [refreshing, setRefreshing] = useState(false); @@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma onChange(resolveConfig(entry, prefs)); }, [entries, onChange, value.provider]); + // If the active provider is disabled in the settings drawer it drops out of + // `entries` (the 5.5 filter) — fall back to boocode so the composer never + // strands on an unselectable provider with empty model/mode pickers. + useEffect(() => { + if (!entries?.length) return; + if (entries.some((e) => e.name === value.provider)) return; + const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0]; + if (!fallback) return; + onChange(resolveConfig(fallback, loadPrefs())); + }, [entries, value.provider, onChange]); + + // 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss + // return), refetch until terminal. Capped; no provider_snapshot_updated WS + // frame (deferred Tier-2). Dormant today since the snapshot awaits the build. + const pollsRef = useRef(0); + useEffect(() => { + const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false; + if (!anyLoading) { + pollsRef.current = 0; + return; + } + if (pollsRef.current >= 10) return; + const t = setTimeout(() => { + pollsRef.current += 1; + void refreshProviderSnapshot(projectPath); + }, 2000); + return () => clearTimeout(t); + }, [allEntries, projectPath]); + const currentEntry = useMemo( () => entries?.find((e) => e.name === value.provider), [entries, value.provider], @@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma value={value.provider} options={providerOptions} onPick={pickProvider} - icon={providerIcon(value.provider)} + icon={ + currentEntry?.status === 'loading' + ? + : providerIcon(value.provider) + } /> void; + /** Fired after a successful add so the parent can refetch the snapshot. */ + onAdded: (id: string) => void; +} + +/** + * v2.3 Phase 5 (design.md §7.3). Search the curated ACP catalog and register a + * provider: PATCH /api/providers/config with its custom-ACP override, then + * refresh that one provider. Adding only edits config — it does NOT install the + * binary, so the provider shows "Not installed" until the CLI is on PATH. + */ +export function AddProviderModal({ open, onOpenChange, onAdded }: Props) { + const [query, setQuery] = useState(''); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return ACP_PROVIDER_CATALOG; + return ACP_PROVIDER_CATALOG.filter( + (e) => + e.id.toLowerCase().includes(q) || + e.label.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q), + ); + }, [query]); + + async function add(entry: AcpCatalogEntry): Promise { + setBusyId(entry.id); + setError(null); + try { + await api.coder.patchProvidersConfig(buildAcpProviderConfigPatch(entry)); + await api.coder.refreshProviders([entry.id]); + onAdded(entry.id); + onOpenChange(false); + } catch (err) { + // 422 from PATCH (invalid override) surfaces here as ApiError.message. + setError(err instanceof Error ? err.message : 'failed to add provider'); + } finally { + setBusyId(null); + } + } + + return ( + + + + Add ACP provider + + Registers the provider in your coder config. It is not installed — install the CLI + yourself; until it's on PATH it shows as “Not installed”. + + + +
+
+ + setQuery(e.target.value)} + placeholder="Search providers…" + className="pl-7" + /> +
+ +
+ {filtered.length === 0 && ( +
No matching providers.
+ )} + {filtered.map((e) => ( +
+
+
+
{e.label}
+
{e.description}
+
+ +
+
+ $ {e.command.join(' ')} +
+
+ + Install {e.label} + + {e.installCmd && ( + + {e.installCmd} + + )} +
+
+ ))} +
+ + {error &&
{error}
} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/coder/ProvidersSettings.tsx b/apps/web/src/components/coder/ProvidersSettings.tsx new file mode 100644 index 0000000..13acc18 --- /dev/null +++ b/apps/web/src/components/coder/ProvidersSettings.tsx @@ -0,0 +1,218 @@ +import { useEffect, useRef, useState } from 'react'; +import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react'; +import { api } from '@/api/client'; +import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types'; +import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; +import { Button } from '@/components/ui/button'; +import { AddProviderModal } from './AddProviderModal'; +import { cn } from '@/lib/utils'; + +/** Map a snapshot entry to a status badge (design.md §7.1 labels). */ +function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } { + if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' }; + if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' }; + if (e.status === 'ready') + return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' }; + if (e.status === 'error') + return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' }; + if (!e.installed) + return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' }; + return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' }; +} + +/** + * v2.3 — provider management as a Settings tab section (design.md §7.1). Lists + * ALL registered providers (including the disabled/unavailable ones the composer + * picker hides). Per row: label + model count, status badge, per-id refresh, + * diagnostic, and an enable/disable toggle. Native boocode is always-on. + * + * Uses the home-cwd snapshot (no project arg) — provider management is global, + * not per-project (design.md §4.5). + */ +export function ProvidersSettings() { + const allEntries = useProviderSnapshot(); + const [config, setConfig] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [diagId, setDiagId] = useState(null); + const [diagText, setDiagText] = useState(null); + + // The raw config is needed to preserve a provider's FULL override when + // toggling: the PATCH replaces an id's override wholesale, so a bare + // { enabled } would wipe a custom ACP provider's command/label. + useEffect(() => { + api.coder + .getProvidersConfig() + .then(setConfig) + .catch(() => setConfig({ providers: {} })); + }, []); + + // While any entry is loading, refetch until terminal (capped, no WS frame). + const pollsRef = useRef(0); + useEffect(() => { + const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false; + if (!anyLoading) { + pollsRef.current = 0; + return; + } + if (pollsRef.current >= 10) return; + const t = setTimeout(() => { + pollsRef.current += 1; + void refreshProviderSnapshot(); + }, 2000); + return () => clearTimeout(t); + }, [allEntries]); + + async function toggle(e: ProviderSnapshotEntry): Promise { + setBusyId(e.name); + setError(null); + try { + const existing: ProviderOverride = config?.providers[e.name] ?? {}; + const resp = await api.coder.patchProvidersConfig({ + providers: { [e.name]: { ...existing, enabled: !e.enabled } }, + }); + setConfig({ providers: resp.providers }); + await refreshProviderSnapshot(); + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to update provider'); + } finally { + setBusyId(null); + } + } + + async function refreshOne(id: string): Promise { + setBusyId(id); + setError(null); + try { + await api.coder.refreshProviders([id]); + await refreshProviderSnapshot(); + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to refresh'); + } finally { + setBusyId(null); + } + } + + async function openDiagnostic(id: string): Promise { + if (diagId === id) { + setDiagId(null); + setDiagText(null); + return; + } + setDiagId(id); + setDiagText('Loading…'); + try { + const { diagnostic } = await api.coder.getProviderDiagnostic(id); + setDiagText(diagnostic); + } catch (err) { + setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic'); + } + } + + const entries = allEntries ?? []; + + return ( +
+
+

+ Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are + hidden from the composer picker but managed here. +

+ +
+ +
+ {allEntries === null && ( +
Loading…
+ )} + {entries.map((e) => { + const badge = statusBadge(e); + const isNative = e.transport === 'native'; + const busy = busyId === e.name; + return ( +
+
+
+
{e.label}
+
+ {e.models.length} model{e.models.length === 1 ? '' : 's'} +
+
+ + {e.status === 'loading' && } + {badge.label} + + + + {isNative ? ( + + Always on + + ) : ( + + )} +
+ {diagId === e.name && ( +
+                  {diagText}
+                
+ )} +
+ ); + })} +
+ + {error &&
{error}
} + + void refreshProviderSnapshot()} + /> +
+ ); +} diff --git a/apps/web/src/components/panes/SettingsPane.tsx b/apps/web/src/components/panes/SettingsPane.tsx index 9f39c5a..36df0b6 100644 --- a/apps/web/src/components/panes/SettingsPane.tsx +++ b/apps/web/src/components/panes/SettingsPane.tsx @@ -15,9 +15,10 @@ import { } from '@/components/ui/dialog'; import { ModelPicker } from '@/components/ModelPicker'; import { ThemePicker } from '@/components/ThemePicker'; +import { ProvidersSettings } from '@/components/coder/ProvidersSettings'; import { cn } from '@/lib/utils'; -type Section = 'session' | 'project' | 'theme'; +type Section = 'session' | 'project' | 'theme' | 'providers'; interface Props { session: Session; @@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
- {(['session', 'project', 'theme'] as const).map((s) => ( + {(['session', 'project', 'theme', 'providers'] as const).map((s) => (
diff --git a/apps/web/src/data/acp-provider-catalog.ts b/apps/web/src/data/acp-provider-catalog.ts new file mode 100644 index 0000000..eb04719 --- /dev/null +++ b/apps/web/src/data/acp-provider-catalog.ts @@ -0,0 +1,83 @@ +import type { ProviderConfigPatch } from '@/api/types'; + +/** + * v2.3 Phase 5 (design.md §7.3) — a SMALL curated catalog of ACP coding agents + * the user might register. We deliberately do NOT port Paseo's 30+ entry list. + * + * Non-goal: we never install anything. Each entry is a manual-install hint + * (`installUrl` / `installCmd`) plus the config `command` that gets written into + * `/data/coder-providers.json`. The user installs the CLI themselves; until the + * binary is on PATH the provider shows as "Not installed". Commands are + * editable after adding — versions are aliased/untrimmed on purpose; pin on your + * own host once verified. + */ +export interface AcpCatalogEntry { + id: string; + label: string; + description: string; + /** Config command written verbatim into providers[id].command: [binary, ...args]. */ + command: [string, ...string[]]; + /** Where to install the CLI manually — we LINK, never install. */ + installUrl: string; + /** Optional suggested install command, shown as a copyable hint. */ + installCmd?: string; +} + +export const ACP_PROVIDER_CATALOG: AcpCatalogEntry[] = [ + { + id: 'amp-acp', + label: 'Amp', + description: 'Sourcegraph Amp — agentic coding CLI with an ACP bridge.', + command: ['amp-acp'], + installUrl: 'https://ampcode.com/', + installCmd: 'npm i -g @sourcegraph/amp', + }, + { + id: 'gemini', + label: 'Gemini CLI', + description: 'Google Gemini CLI in ACP mode (--experimental-acp).', + command: ['gemini', '--experimental-acp'], + installUrl: 'https://github.com/google-gemini/gemini-cli', + installCmd: 'npm i -g @google/gemini-cli', + }, + { + id: 'cline', + label: 'Cline', + description: 'Cline coding agent over ACP (run via npx).', + command: ['npx', '-y', 'cline', '--acp'], + installUrl: 'https://cline.bot/', + }, + { + id: 'claude-code-acp', + label: 'Claude Code (ACP)', + description: "Zed's ACP adapter for Claude Code — distinct from the built-in PTY claude provider.", + command: ['npx', '-y', '@zed-industries/claude-code-acp'], + installUrl: 'https://github.com/zed-industries/claude-code-acp', + }, + { + id: 'pi-acp', + label: 'Pi', + description: 'Example custom ACP entry — build the binary from source, then edit the command.', + command: ['pi-acp'], + installUrl: 'https://agentclientprotocol.com/', + }, +]; + +/** + * Build the PATCH body that registers a catalog entry: a single-id partial + * providers map with the custom-ACP override (extends:'acp' + label + command), + * enabled. Sent to PATCH /api/providers/config (then refreshProviders([id])). + */ +export function buildAcpProviderConfigPatch(entry: AcpCatalogEntry): ProviderConfigPatch { + return { + providers: { + [entry.id]: { + extends: 'acp', + label: entry.label, + description: entry.description, + command: entry.command, + enabled: true, + }, + }, + }; +} diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index 8a0f72d..101193f 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined { // v1.9: settings pane factory. No chats, no state beyond identity — the // SettingsPane component renders Session/Project sections from the // surrounding session/project. -function settingsPane(): WorkspacePane { - return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 }; +function settingsPane(id: string = generateId()): WorkspacePane { + return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 }; } // v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with @@ -135,7 +135,7 @@ export interface UseWorkspacePanesResult { // Open-on-first-click, close-on-second-click. Singleton — settings panes // don't count toward MAX_PANES. Closing the only remaining pane (edge case) // falls back to an empty pane to preserve the "always one pane" invariant. - toggleSettingsPane: () => void; + toggleSettingsPane: () => string | null; removePane: (idx: number) => void; removeChatFromPanes: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void; @@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { return success ? newPaneId : null; }, [seedPaneChat]); - const toggleSettingsPane = useCallback(() => { + // Returns the new settings pane id when one is OPENED (so mobile callers can + // push ?pane= atomically — see addPaneAndSwitch), or null when it was closed. + // Id generated outside the updater so a strict-mode double-invoke agrees. + const toggleSettingsPane = useCallback((): string | null => { + const newPaneId = generateId(); + let openedId: string | null = null; setPanes((prev) => { const existingIdx = prev.findIndex((p) => p.kind === 'settings'); if (existingIdx < 0) { - const next = [...prev, settingsPane()]; + const next = [...prev, settingsPane(newPaneId)]; setActivePaneIdx(next.length - 1); + openedId = newPaneId; return next; } + openedId = null; if (prev.length <= 1) { setActivePaneIdx(0); return [emptyPane()]; @@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); + return openedId; }, []); const removePane = useCallback((idx: number) => { diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 3a4353c..ffc335b 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) { }; }, [sessionId]); + // v2.3: opening the settings pane on mobile must push ?pane= atomically, or + // the URL-sync effect below snaps activePaneIdx back to the chat pane and the + // settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane + // returns the new pane id when it opens (null when it closes → drop ?pane= so + // the effect falls back to pane 0). Desktop has no URL pane state — no-op. + const toggleSettingsAndSync = useCallback(() => { + const openedId = panesHook.toggleSettingsPane(); + if (!isMobile) return; + const params = new URLSearchParams(location.search); + if (openedId) params.set('pane', openedId); + else params.delete('pane'); + navigate(`${location.pathname}?${params.toString()}`); + }, [panesHook, isMobile, navigate, location.pathname, location.search]); + useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type === 'session_renamed' && event.session_id === sessionId) { @@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) { // Sidebar Settings button broadcasts this when a session is mounted; // toggleSettingsPane opens on first click, closes on second. if (event.type === 'open_settings_pane') { - panesHook.toggleSettingsPane(); + toggleSettingsAndSync(); } }); - }, [sessionId, editingName, navigate, project, panesHook]); + }, [sessionId, editingName, navigate, project, toggleSettingsAndSync]); // v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so // MobileTabSwitcher's onSwitchPane can push the same URL state and the diff --git a/docs/DEFERRED-WORK.md b/docs/DEFERRED-WORK.md index 34cbdc8..b549cf9 100644 --- a/docs/DEFERRED-WORK.md +++ b/docs/DEFERRED-WORK.md @@ -2,7 +2,7 @@ This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch. -Last updated: 2026-05-26 +Last updated: 2026-05-29 --- @@ -11,7 +11,7 @@ Last updated: 2026-05-26 | Item | Category | User impact | Effort | Risk if left alone | |------|----------|-------------|--------|-------------------| | Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees | -| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 5–30s on cache miss | Medium (v2.3 batch) | Slow provider picker; repeated ACP spawns on every snapshot rebuild | +| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 5–30s on cache miss | ✅ Shipped (v2.3, Phase 2) | Resolved — `PROVIDER_PROBE_TTL_MS` TTL gate live | | Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web | | Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts | | Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client | @@ -111,7 +111,7 @@ There is also **no frontend** calling task cancel today (`grep` across `apps/web ## 2. Skip ACP cold probe when DB models are fresh -**Status:** Planned — [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). **Not shipped** (no `v2.3` tag; all tasks unchecked). +**Status:** ✅ **ADDRESSED** in v2.3 (phases 1–5: `v2.5.4-provider-lifecycle-phase1` … `v2.5.12-provider-lifecycle-phase4`, plus the phase-5 settings UI + picker filter). The `PROVIDER_PROBE_TTL_MS` (default 24h) gate on `available_agents.last_probed_at` is live — the tier-2 cold ACP probe runs only on `force` (`POST /api/providers/refresh`), TTL staleness, or empty DB models; otherwise the snapshot serves cached models. See [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). The original (v2.2) behavior below is kept for history. ### Current behavior (v2.2) @@ -140,12 +140,21 @@ See [`design.md`](../openspec/changes/v2-3-provider-lifecycle/design.md): v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag. -### Acceptance criteria (when v2.3 ships) +### Acceptance criteria — met -- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in tests) -- Disabled provider visible in settings, absent from composer +- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in `provider-snapshot.test.ts`) +- Disabled provider visible in settings (Providers tab), absent from composer - Explicit refresh repopulates models; warm open is sub-second +### Still deferred (Tier-2 follow-ups, not shipped in v2.3) + +These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open: + +- **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1). +- **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2). +- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below). +- **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11). + --- ## 3. Unified `packages/types` for provider snapshot JSON