diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1ba4651e8..b6425bfae 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -192,6 +192,7 @@ jobs: packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/runtime-env.test.ts packages/ui/src/lib/server-meta.test.ts + packages/ui/src/lib/theme-scheme.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts @@ -203,12 +204,14 @@ jobs: packages/ui/src/stores/client-state-partitions.test.ts packages/ui/src/stores/client-state.test.ts packages/ui/src/stores/form-settlements.test.ts + packages/ui/src/stores/instance-invalidation.test.ts packages/ui/src/stores/message-prompt-display.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/message-window.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts + packages/ui/src/stores/plugin-activation.test.ts packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts diff --git a/AGENTS.md b/AGENTS.md index d9339d064..076d9fb7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,20 @@ - Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size. - Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere. - Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions. +- Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. +- Session rows keep actions inline until their measured title, badges, and controls no longer fit. Keep responsive action styles in `styles/components/session-row-actions.css`; hidden inline controls remain measurable but inert, and an open overflow menu stays mounted until dismissal. +- Session hierarchy geometry lives in `styles/components/session-tree.css`; connector axes follow the parent expander at every depth, including selection mode, RTL and touch layouts. - Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change. +- Explicit round exceptions: Yolo and MCP switches (shared `styles/components/switches.css` geometry), overlay drawer navigation buttons, and floating message scroll buttons. Other chrome remains square. +- Tags and numeric/context/token labels also use rounded geometry via `--chip-radius` (`--pill-radius` is an alias). Register badge variants in `styles/components/badges.css`; use `.badge-shape` for utility-styled labels rather than adding a local radius. +- The message-content popup and Chat settings share `components/transcript-visibility.ts`; tool presentation metadata lives independently of renderers in `components/tool-call/tool-presentation.ts`. Popup styles live in `styles/components/transcript-filters.css`. +- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css` and preserve compact-layout hiding. - Document any new styling conventions or directory additions in this file so future changes remain consistent. +- Soft palette families live in `packages/ui/src/lib/soft-color-schemes.ts`, with references in `dev-docs/PALETTE_SOURCES.md`. Keep selection independent of participant identity, and keep transcript/composer surfaces distinct. Run `palette-quality.test.ts` and inspect real rendered captures when changing palette colors or their token mapping. +- Palette settings follow the resolved appearance in Auto mode. Keep the picker/actions beside the two-row swatch grid, stacking via the `palette-settings` container at narrow widths. Swatch styles live in `styles/components/theme-scheme-swatches.css`. +- Appearance mode and the saved light/dark selections are independent (`lib/appearance-preferences.ts`). Message/tool cards use the muted surface, inset output and the composer use the base canvas, and preferences use the same secondary surface as the main panels. Use `--surface-hover-overlay` for a subtle local rollover; preserve selected backgrounds beneath that overlay instead of replacing them with a generic panel color. +- Right-panel base-canvas button rollover overrides live in `styles/panels/control-hover.css`; do not substitute the secondary surface merely to show hover. +- Project and right-panel tabs share `components/tab-scroll.tsx` and `styles/components/tab-scroll.css`. Keep their native scrollbar above upright content without mirrored transforms, negative border overlaps or permanent compositing hints. Validate shared scrollbar styling and adjoining edges at fractional zoom in the browser and isolated Electron renderer fixtures (`tests/browser/tab-chrome.test.ts`). ## Coding Principles diff --git a/DESKTOP_V2_COMPARISON.md b/DESKTOP_V2_COMPARISON.md index 1ee1c1497..f0f73e6f3 100644 --- a/DESKTOP_V2_COMPARISON.md +++ b/DESKTOP_V2_COMPARISON.md @@ -4,9 +4,12 @@ This review compares: -- CodeNomad `DEV-v2` at `dea20996` (2026-09-03). -- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/beta` at `eb1ac54d73` (2026-08-25). -- CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-18999`, while the runtime CLI version is managed independently. +- CodeNomad `feat/ui-harmonization` after the `733f5bf9` merge of `DEV-v2` (2026-09-04). +- The initial official OpenCode Desktop V2 baseline at `eb1ac54d73` (2026-08-25), which produced `beta-18230`. +- The latest published OpenCode V2 beta source at `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3` (2026-09-04), which produced `beta-19059`. +- The matching `upstream/beta` head, with no later unpublished contract delta at review time. + +CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-19059`. The independently managed runtime used to verify the compatibility fallback still reports `beta-18999`; startup intentionally has no exact client/runtime version gate. The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. Older `v2`, `opencode-2-0`, and intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. @@ -16,7 +19,7 @@ This is an incremental review. It does not repeat issues already closed by CodeN CodeNomad implements the important V2 architecture rather than emulating the V1 desktop model. It uses the native client contract, locations, shared service, sessions, messages, Forms, permissions, providers, Shells, worktrees, and event stream. Its multi-window and cross-host restore implementation is broader than the official Electron-only desktop implementation. -The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, and proxy route coverage. Those defects are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors. +The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, plugin readiness, event handling, durable metadata, and proxy route coverage. The accompanying changes close the published-contract defects. The remaining differences are release hardening and optional Desktop workflows. None requires restoring V1 code or replacing native V2 cursors. ## Closed Findings @@ -46,11 +49,43 @@ The comparison and subsequent beta-contract audit found concrete CodeNomad defec ### Failed plugin inventory -**Previous behavior:** Metadata projection called `startsWith` on every plugin ID. Current OpenCode V2 can report a failed plugin without an ID, which would break metadata refresh after a client upgrade. +**Previous behavior:** Metadata projection first assumed every plugin had an ID, then accepted every string ID as active. The current contract permits failed plugins both with and without an ID, so a named failure could be displayed with the same healthy indicator as an active plugin. + +**Official behavior:** `PluginInfo` has an optional `id` and a required `state` discriminator whose status is `active` or `failed`. + +**Resolution:** CodeNomad projects only non-builtin plugins with a string ID and `state.status === "active"`. Failed records remain available in the native inventory but are no longer represented as healthy in the legacy name-only status list. + +### Asynchronous plugin activation + +**Previous behavior:** Initial agent, provider, model, command, and plugin reads could run while a Location's configured plugins were still installing or activating. CodeNomad could therefore retain a transiently incomplete catalog until another event forced a refresh. + +**Official behavior:** `beta-18999` adds `POST /api/plugin/await-activation`; the official ACP client waits on it before caching a Location catalog. OpenCode also replaces `plugin.added` with the settled `plugin.updated` event. + +**Resolution:** The proxy exposes only the non-mutating activation wait, catalog and plugin-status reads wait for it, concurrent waits for one client and Location coalesce, and an unsupported lagging runtime falls back to authoritative reads. `plugin.updated` now refreshes agents, providers, commands, and metadata; the obsolete `plugin.added` branch is removed. `plugin.check`, `plugin.update`, and generic plugin RPC remain blocked. + +### Active-location MCP and plugin status + +**Previous behavior:** Metadata requests and their loaded-state check used the instance root even when the selected session belonged to a worktree with different `.opencode` configuration. + +**Official behavior:** Location-scoped status follows the selected session, and request inputs encode native `workspaceID` values as the wire-level `location[workspace]` selector. + +**Resolution:** MCP, plugin activation, and plugin inventory reads now use the active `SessionInfo.location`; metadata readiness is keyed by the location returned from MCP; session switches trigger a new status load; and MCP toggles continue to use that same returned location. Replaced clients and superseded locations cannot commit stale metadata. + +### Filesystem list ownership -**Official behavior:** `packages/schema/src/plugin.ts` defines active and failed plugin variants, with an optional ID for failures. +**Previous behavior:** The proxy authorized the native Location for `fs.list` but did not separately authorize its `path` query. The official contract permits an absolute path or traversal to parents and siblings, so a workspace-scoped caller could ask the shared daemon to list an unrelated directory. -**Resolution:** CodeNomad now accepts only string IDs when projecting its current plugin-name list. An ID-less failed record can no longer prevent project, MCP, and plugin metadata from loading. +**Official behavior:** `fs.list` keeps the requested Location while resolving its optional path independently; returned entry paths remain relative to that Location. + +**Resolution:** CodeNomad resolves relative targets against the authorized Location, rejects duplicate path selectors and targets outside owned worktrees, and translates accepted paths into the shared service namespace for WSL. Parent and sibling browsing remains possible only inside owned worktrees. + +### Durable session and message additions + +**Previous behavior:** The local session adapter reconstructed `SessionInfo` without its durable JSON `metadata`, `session.created` also dropped that metadata, and the local message-info time shape omitted the new `streamed` boundary. A recovered `session.step.streamed` event alone did not mark an idle local session as working. + +**Official behavior:** Published V2 sessions carry optional `SessionMetadata`; assistant messages carry `time.streamed`; `session.step.streamed` and `session.message.content.updated` are native durable events reduced by `@opencode-ai/client/solid`. + +**Resolution:** REST and event session projections retain metadata, message projection retains streamed time, a streamed step restores working status after an event gap, and focused tests verify authoritative assistant-content replacement through the generated Solid reducer. ### Deprecated models @@ -95,15 +130,30 @@ The review reconfirmed these areas and found no current incompatibility: Service stop removal is intentional: CodeNomad does not own the shared daemon. Upstream session sharing is disabled, so its absence is not a parity gap. Upstream's temporary SSE heartbeat change was reverted and requires no CodeNomad change. -## Remaining Correctness Work +## Latest Published Beta Audit -### Active-location MCP and plugin status +The official `anomalyco/opencode-beta` repository published 22 beta tags from `beta-18230` through `beta-19059`. Their GitHub release bodies are empty, so there are no prose release notes to review. This audit instead matched every successful publish workflow to its source commit, read the intervening official commits, compared npm artifacts and generated declarations, and checked the official V2 documentation index and relevant API/client pages. -**Priority:** Medium. **Client upgrade required:** No. +The final refresh is exact: + +- `beta-18866` was built from `519cd8c7712fc2ca6d2ca1d356d7f52cbd6d5808`. +- `beta-18999` was built from `887f319769c55718e3e64f64b32c9aafb13c5d66`. +- `beta-19059` was built from `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3`. +- The ranges contain 114 commits from `beta-18866` to `beta-18999`, then 52 commits to `beta-19059`. +- The Promise client remains at 136 routes instead of 135: the only added route is `POST /api/plugin/await-activation`. +- The only added exported types are `PluginAwaitActivationInput` and `PluginAwaitActivationOutput`; `PluginAdded` is removed. +- `V2Event` removes `plugin.added`; `ConfigEntry.autoupdate?: boolean | "notify"` first becomes `update?: "disable" | "notify" | "auto"`, then `beta-19059` removes the `"auto"` value. +- `beta-19059` adds optional compaction `model` and `providerState`, the command-config `subagent` flag, and guarded Solid event refreshes with `onError`; it adds no route or exported type name. + +CodeNomad consumes the activation boundary and current event/state shapes, preserves the new compaction metadata, logs bounded Solid refresh failures, authorizes the independently resolved filesystem-list target, and avoids falsely marking a parent busy when a command may spawn a background subagent. It has no OpenCode update-setting caller to migrate. Runtime-side fixes—plugin activation stability, session-entry readiness, configuration and symlink watching, abandoned-compaction settlement, provider identity/state preservation, Location retry, command-subagent backgrounding, and Windows interruption—are acquired from the independently updated OpenCode runtime rather than duplicated in CodeNomad. Official App/Desktop/TUI-only navigation, styling, terminal-pane, timeline-detail, and plugin-dialog changes were reviewed as product references, not treated as wire requirements. -`packages/ui/src/lib/hooks/use-instance-metadata.ts` currently queries MCP and plugin state with the instance root. The active session may belong to a worktree or nested location with different `.opencode` configuration. Official Desktop derives the status location from the selected session in `packages/app/src/pages/session.tsx` and `status-popover-body.tsx`. +### Beta 19059 delta -The CodeNomad metadata request and cache authority should be keyed by the active `SessionInfo.location`, and MCP toggles should use that same location. This needs a focused state change rather than a root fallback patch because switching tabs must not display or mutate another location's MCP state. +The previously failed publish was rerun successfully on 2026-09-04. The OpenAPI remains at 119 paths, 140 operations, and 229 schemas. The generated Promise surface remains at 136 methods. The full declaration delta from `beta-18999` is the optional compaction model/provider state, command-config `subagent` plus deprecated `subtask`, and removal of the `"auto"` update mode; the Solid helper separately adds connection/disposal guards and `onError` for event-triggered reads. + +All 52 intervening commits were classified. Core and client correctness fixes flow through the upgraded client or independently updated runtime. The now-published official Desktop session-import action remains an optional CodeNomad product workflow because the ownership-validated native import route already exists without requiring UI parity. At review time `upstream/beta` points to the same source commit, so there is no unpublished head to represent as shipped behavior. + +## Remaining Correctness Work ### Signed desktop releases @@ -121,10 +171,15 @@ Generated types, proxy routes, events, plugin inventory, Forms, sessions, and re ## Optional Feature Gaps -These are official Desktop capabilities, not migration blockers: +These are official Desktop or published native V2 capabilities, not migration blockers: -- **Interactive PTY UI:** CodeNomad proxies native PTY lifecycle routes but has no embedded terminal, connect-ticket WebSocket, resize, reconnect, or restore UI. The existing external terminal action and background Shell panel are not equivalent. -- **Session export:** Official Desktop paginates and exports a complete session. CodeNomad has no export command or allowlisted export route. +- **Interactive and persistent PTY UI:** CodeNomad proxies standard PTY lifecycle routes but has no embedded terminal, connect-ticket WebSocket, resize, reconnect, or restore UI. Published `experimental.persistentPty.*` session-terminal routes remain blocked because CodeNomad has no corresponding ownership lifecycle. The external terminal action and background Shell panel are not equivalent. +- **Session transfer UI:** The ownership-validated `session.import` route is allowlisted, but CodeNomad has no import action. Official Desktop now ships an import action and paginates before exporting a complete session; CodeNomad has no export command or allowlisted export route. +- **Native session analytics:** `session.stats` can provide server-side activity, model, token, and tool aggregates. CodeNomad has per-session usage presentation but no native statistics dashboard or allowlisted stats route. +- **Plugin package management:** `plugin.check` and `plugin.update` are not exposed. A future inventory UI can add the read-like check, while executable package updates require an explicit trusted confirmation and mutation policy. +- **Plugin diagnostics and capabilities:** The current status panel projects active non-builtin plugin names. It does not yet expose failed-plugin errors, sources, update state, or `PluginFeatures`; those require a richer typed inventory UI rather than treating failures as healthy names. +- **Typed plugin RPC:** Generic RPC remains blocked until CodeNomad intentionally installs a reviewed plugin contract and can authorize each method; exposing the wildcard endpoint would bypass the proxy's narrow capability model. +- **Review-base and non-Git diffs:** Published `vcs.base` plus committed/base diff inputs can support branch review and arbitrary VCS backends. CodeNomad currently keeps its validated Git status, diff, stage, unstage, and commit boundary. - **References and MCP resources:** Official prompt suggestions can attach configured references and MCP resources. CodeNomad's picker currently offers agents, files, and commands only. Implement these when product scope requires them. They should use the existing native V2 APIs; no compatibility abstraction or V1 fallback is needed. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 32b187632..92de3942a 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format. -Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The tested 2026-09-03 client and runtime snapshot is `beta-18999`. +Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The 2026-09-04 client snapshot is `beta-19059`; the independently managed runtime used for backward-compatibility validation still reports `beta-18999`. The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md). @@ -27,14 +27,15 @@ shared daemon is never stopped by this lifecycle. - Use native locations and `SessionInfo.location` as the authority for workspace, session, file, event, Shell, PTY, and Git worktree ownership. - Use native APIs for projects, sessions, messages, prompts, commands, models, agents, providers, MCP, permissions, Forms, files, VCS, instructions, Shells, and PTYs. -- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. +- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.step.streamed`, `session.message.content.updated`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. - Use `@opencode-ai/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. -- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-18866` declares no Question client API and its runtime does not serve those routes. +- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-19059` declares no Question client API and its runtime does not serve those routes. - Replace shell-mode prompts with native `session.shell`. - Replace CodeNomad background processes with native `shell.*` resources. The Status UI lists, displays bounded output for, and removes Shells; create/output/timeout routes remain available through the ownership-checked proxy. Interactive `pty.*` terminals remain separate. - Store voice-mode instructions with `session.instructions.entry` and synchronize them before prompts, commands, and session Shell calls. - Inherit native durable JSON `SessionMetadata` directly from `SessionInfo`. Do not widen it to arbitrary `unknown` values or maintain a parallel CodeNomad-only metadata contract. -- Keep the narrow project-local `codenomad.automation` exception on the V2 `setup` and `tool.transform` contract; it remains active under `beta-18999`. +- Wait for location-scoped plugin activation before retaining agent, provider, model, command, or plugin catalog reads. Treat `plugin.updated` as the settled catalog invalidation; `plugin.added` no longer exists. +- Keep the narrow project-local `codenomad.automation` exception on the V2 `setup` and `tool.transform` contract; it remains active under `beta-19059`. ### Beta 18866 Contract Review (Historical) @@ -49,7 +50,36 @@ The `beta-18414` to `beta-18866` review found these additive client surfaces: | `vcs.base` and diff base selection | Deferred read-only candidate. | The current Git Changes UI displays working-tree and index changes and does not yet offer base-branch comparison. | | `Service.stop({ pty })` handoff/clear behavior | Do not adopt. | CodeNomad does not own or stop the shared OpenCode service. | -The subsequent refresh to `beta-18999` retained these integrations. Both UI and server resolve the same client version, and the independently managed runtime used for the final native validation also reported `beta-18999`. +### Beta 18999 Contract Review (Historical) + +The 21 official beta releases from `beta-18230` through `beta-18999` have empty GitHub release bodies. The 2026-09-04 review therefore matched official publish workflows to source commits, read the intervening repository history, compared npm tarballs and generated Promise declarations, and checked the official V2 docs instead of relying on nonexistent prose release notes. + +The final published range from `beta-18866` (`519cd8c7712fc2ca6d2ca1d356d7f52cbd6d5808`) to `beta-18999` (`887f319769c55718e3e64f64b32c9aafb13c5d66`) contains 114 commits. Its complete generated-contract delta is: + +| Published change | CodeNomad decision | +| --- | --- | +| Add `POST /api/plugin/await-activation` and its input/output types. | Allowlist only this non-mutating plugin route. Coalesce waits by client and Location before catalog and plugin-status reads, with authoritative reads as the fallback for a lagging runtime. | +| Remove `PluginAdded` and `plugin.added`. | Delete the obsolete invalidation branch. Refresh agents, providers, commands, and metadata from the settled `plugin.updated` event. | +| Rename `ConfigEntry.autoupdate` to `update: "disable" \| "notify" \| "auto"`. | No migration is required because CodeNomad has no OpenCode `autoupdate` caller or projection. Runtime CLI updates remain independently managed. | + +The same audit reconfirmed the cumulative published additions already in use: native `session.messageUpdate`, durable session metadata, `time.streamed`, `session.step.streamed`, `session.message.content.updated`, current plugin state records, and the generated Solid event reducer. Provider/model canonical resolution and materialized skill text remain upstream runtime concerns; CodeNomad sends native IDs and prompt attachments without duplicating that lowering. Plugin package checks/updates, generic RPC, persistent PTYs, VCS review-base reads, and session transfer UI remain explicit product/security decisions rather than accidental omissions. + +### Beta 19059 Contract Review + +The successful 2026-09-04 publish from `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3` produced `beta-19059`, the 22nd official beta in the reviewed range. Its release body is also empty. The audit therefore reviewed all 52 commits after `beta-18999`, compared both npm tarballs, refreshed the installed declarations and lock, and compared the official V2 OpenAPI and documentation. + +No route or schema was added or removed: the OpenAPI remains at 119 paths, 140 operations, and 229 schemas, while the generated Promise client remains at 136 methods. The complete published client delta that affects CodeNomad is: + +| Published change | CodeNomad decision | +| --- | --- | +| Completed compaction messages and `session.compaction.ended` now carry optional `model` and `providerState`. | Preserve both fields in normalized compaction parts; the upgraded Solid reducer also retains them for live events. | +| `createData` fences event-driven refreshes while disconnected or disposed and adds an `onError` callback. | Keep the existing connection signal and route refresh failures through the CodeNomad logger. The lifecycle fencing is inherited from the upgraded client. | +| Command config adds `subagent`; `subtask` remains as a deprecated alias. | `CommandInfo` exposes neither flag, so the runtime remains authoritative. Command submission stays serialized but no longer marks the parent optimistically busy; native events now distinguish current-session execution from a background child. | +| `ConfigEntry.update` narrows from `"disable" | "notify" | "auto"` to `"disable" | "notify"`. | No migration is required because CodeNomad does not read or write this setting; update checks moved to CLI/TUI clients and the runtime CLI remains independently managed. | + +The renewed OpenAPI review also made explicit that `fs.list` accepts absolute paths and `..` traversal relative to a Location. The CodeNomad proxy now resolves that target, rejects duplicate selectors and paths outside owned worktrees, and translates an accepted path for WSL before forwarding it. + +Runtime changes in the same release—settling abandoned compactions before resume, retrying failed Location initialization, disabling plugins after transform failures, backgrounding command subagents, restoring Windows terminal interruption, detecting new ecosystem config roots, and live provider/model fixes—are acquired automatically when the independently managed `opencode2` runtime is updated. CodeNomad does not duplicate those internals and does not reject an older healthy runtime at startup. At the time of this audit `upstream/beta` equals the published `beta-19059` source, so there is no later unpublished contract delta. ## Shared Service Model @@ -202,11 +232,20 @@ At the 2026-09-03 timeline stabilization head (`dea20996`): - UI TypeScript typecheck passed. - All 68 focused timeline, pagination, request-authority, and restore tests passed. -- The Tauri release build passed against the `beta-18999` lock. +- The Tauri release build passed against the `beta-18999` lock; `beta-19059` validation is recorded with the final branch checks. - Native Developer Mode validation observed in-place capped-window shifts with no remount or empty frame, same-cycle growth compensation, preserved manual escape, and inactive-tab anchor restoration within 0.3125 px. +At the 2026-09-04 `beta-19059` branch gate: + +- The installed UI/server Promise client, protocol, schema, npm metadata, 119 paths, 140 operations, 229 schemas, and 136 Promise methods all resolve to `0.0.0-beta-19059`. The 22 beta publications from `beta-18230` through `beta-19059`, the 52 commits after `beta-18999`, generated declarations, OpenAPI snapshot, official repository head, and V2 documentation were reviewed; no later unpublished contract delta existed. +- UI, Electron, and server TypeScript typechecks passed. The pure UI suite passed 323 tests, the browser-runtime UI suite passed 148, and the server suite passed 373 with the two expected Windows skips. +- Desktop resource integrity passed 3 tests, the complete Electron native suite passed, and Tauri passed all 139 Rust tests serially. The 14 cross-host election tests also passed eight consecutive Windows runs after replacing the slow WMI process-identity probe with `Get-Process.StartTime` on both hosts. +- The production UI build and Tauri release/NSIS build passed. The rebuilt release was relaunched without stopping the shared OpenCode daemon. +- Native Developer Mode validation covered the `system` palette merge reset, the 14-color custom palette, notification anchoring/outside-click/`Escape` focus restoration, active-composer agent/model/thinking shortcuts, and the responsive/touch layout at `320x800`; document width remained 320 px, all five native menus remained available, and both drawers remained reachable. +- The independent compatibility runtime still reports `beta-18999`. It exercised the intentional authoritative-read fallback without an exact startup gate; updating that globally managed CLI to `beta-19059` remains independent of this client/build gate. + ## Review Notes - The generated V2 client remains experimental. Review its current documentation, installed declarations, proxy/API parity, runtime health, and `/api/plugin` failures whenever the beta contract changes. The SDK documentation describes an alternative embedded host; CodeNomad uses the network client. -- V1-style global plugins are outside the CodeNomad client migration. Under the reviewed V2 contract through `beta-18999`, the installed After Effects, Blender, Microsoft 365, Resolve, Unreal, Ponytail, and Gemini Auth integrations require independent migrations to a V2 definition with an `id` and `setup` or `effect`. -- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. +- V1-style global plugins are outside the CodeNomad client migration. Under the reviewed V2 contract through `beta-19059`, the installed After Effects, Blender, Microsoft 365, Resolve, Unreal, Ponytail, and Gemini Auth integrations require independent migrations to a V2 definition with an `id` and `setup` or `effect`. +- Upgrade references: [OpenCode beta releases](https://github.com/anomalyco/opencode-beta/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. diff --git a/dev-docs/PALETTE_REVIEW_2026-09-08.md b/dev-docs/PALETTE_REVIEW_2026-09-08.md new file mode 100644 index 000000000..44f869f06 --- /dev/null +++ b/dev-docs/PALETTE_REVIEW_2026-09-08.md @@ -0,0 +1,246 @@ +# Palette review — 2026-09-08 + +## Subsequent user-approved refinements + +- Both historical appearances are labeled Classic and lead their respective + filtered lists. V1 references are `52f0e629^:packages/ui/src/styles/tokens.css`. + Light Classic restores its separate near-white tool and blue-gray code + surfaces; dark Classic restores its historical participant colors. Saved + custom colors remain intact. +- YOLO now renders with the palette accent; its redundant editor field is + hidden, while legacy stored values remain preserved. +- Shared header shading uses 75% secondary / 25% base, with chrome aliasing + that token. The top title/menu bar uses 25% secondary / 75% base. +- The timeline stops above composer controls. A single horizontal separator + closes it; a chrome-colored blank area remains below it. Composer buttons + are flat, flush, separated, and no longer expose bottom container padding. +- Usage chips and context text have 50% opacity. Message headers use the + timeline's role icons, replacing visible You and preceding the agent name. +- Latest verification: 71 palette/appearance tests rerun successfully, UI + typecheck and production build passed. Earlier browser counts below predate + these refinements and are not a full regression run of this final state. + +## Final surface and appearance follow-up + +The historical audit and first implementation below are retained as a record. +The user's subsequent review supersedes the initial surface mapping and picker +grouping: + +- General starts with appearance settings. Auto / Dark / Light is independent + of the saved light and dark palettes. Fixed modes filter the flat palette + list; Auto offers both editable slots. Classic is labeled simply Classic. +- Existing selections migrate without writes or recoloring custom data. The + inactive palette, named preset identity and colors survive mode changes and + reload. Deleting a preset preserves the selected slot's actual colors. +- Restore Classic's dev mapping: canvas/composer and inset output `#1A1A1A`, + panels `#2A2A2A`, assistant/tool cards `#212529`. The initial implementation + incorrectly mapped assistant/tool cards to the panel surface and inset output + to the muted surface; merely changing palette hex values could not fix that. +- All families now use those semantic roles consistently. Preferences content + and navigation use the same secondary surface as main panels instead of a + darker base canvas/chrome mixture. Palette controls reuse the shared control + surface. +- Chat, session rows, right-panel rows/cards/tabs and preferences navigation + share a local 4% text-color hover overlay. Selected backgrounds remain below + that overlay; focus outlines and disabled semantics are not replaced by hover. + Base-canvas toolbar buttons keep their own canvas instead of jumping to the + secondary surface on interaction. + +Validation includes pure palette/migration tests, mocked queued storage writes, +the real Solid appearance editor and ToolCall in independent browser fixtures, +and native screenshots. The new browser suite checks 17 palettes across 12 +hover targets (204 combinations), mode filtering and persistence. The isolated +native settings check covers 14 cases, including Auto, reload and LTR/RTL. +The production UI was rebuilt and copied only to the already-running isolated +Tauri build. No user draft, shared service or other desktop process was stopped. + +Final checks: 228 targeted UI tests, 26 browser tests, and the two appearance +browser tests rerun after the selected-file hover fix all passed. UI typecheck, +production build and diff check passed. Native Classic/preferences screenshots +and idle/hover crops are in `%LOCALAPPDATA%/Temp/opencode/pr667-*-native.png` +and `pr667-native-rollovers.png`; the 17 browser palette captures are in +`pr667-surfaces/`. The isolated host remains open on Classic. + +The merge reconciliation and previously authorized palette/UI work were pushed +as `f5d28ead` and `306da15b`; the later appearance/surface refinements are +published together in the follow-up UI commit. + +## Scope and evidence + +- Compare `feat/ui-harmonization` at `b11e85c0` with fetched `origin/dev` at `e5115fe8`. +- Review definitions, derived CSS tokens, palette persistence, editor behavior and tests. +- Read the isolated validation profile without changing its palette or preferences. +- `D:\zed` is absent on this machine. Zed preferences are in `%APPDATA%/Zed/settings.json` and select **Ayu Light** / **One Dark**. No theme JSON files were found in the local installation or installed extensions. +- Reference the official Zed theme JSON at commit `6f73c7d0a4aae8e32afb5d01b0fcb89e5e3642ff`, rather than claiming it is the exact installed binary revision. +- Additional light-theme references are Zed extensions linked from the official extension registry. +- This is an audit and proposed correction plan: no palette definitions or user settings were changed during the review. + +## Findings + +### 0. Flattened dark surface hierarchy — high priority + +The review must not reduce the dark-theme regression to accent collisions or similarity between palette swatches. Comparing the actual CSS consumers with `origin/dev` confirms a separate rendering regression: + +- In `dev`, `--message-assistant-bg` resolves through `--message-tool-bg` (the dark CSS default is `#212529`). The UI branch replaces it with `surfaceBase`, both in static tokens and in generated palette properties. +- `.message-item-base` and multiple assistant/reasoning/tool surfaces consume `--message-assistant-bg`. The prompt wrapper and input consume `--surface-base`. They now receive exactly the same background, where `dev` distinguished them. This is a direct cause of darker, flattened message surfaces. +- The user-message background lost its role tint: `mix(userAccent, surfaceSecondary, …)` became plain `surfaceSecondary`. +- Tool surfaces now use a generated mix of `surfaceMuted` and `surfaceBase`, while sidebar controls moved from `surfaceSecondary` to a blend towards `surfaceBase`. These changes also need comparison on the rendered application, not independent approval based on their formulas. +- The three declared surface colors of the inherited Basalt, Fjord, Lichen, Velvet and Ember palettes were not changed by this branch. Their appearance nevertheless changed because the mapping of colors to UI regions changed. Unchanged hex values do **not** demonstrate preserved palette behavior. + +**Correction priority:** use `dev` as the baseline for the inherited dark palettes and restore readable separation of conversation, composer, panels and nested blocks. Preserve the user's validated geometry and quiet chrome. Zed references should inform the mapping, not justify replacing established dark palettes or making every surface equally dark. Validate both differentiation *within* each palette and differentiation *between* palettes with matching real screenshots before claiming a fix. + +### 1. Introduced semantic collisions — high priority + +`packages/ui/src/lib/theme-scheme.ts` now assigns the same color to distinct roles: + +| Palette | Colliding roles | Value | +| --- | --- | --- | +| Fjord | user / primary accent / Yolo | `#67C9BA` | +| Lichen | user / primary accent / Yolo | `#A9C47F` | +| Velvet | agent / primary accent / Yolo | `#E5A77D` | +| Ember | user / primary accent / Yolo | `#D79A66` | +| Dawn (new) | user / primary accent / Yolo | `#287DB5` | +| Parchment (new) | user / primary accent / Yolo | `#0B6678` | + +Highlight, focus, selected lists and dropdowns are all derived from `accentPrimary`. Therefore these are real semantic collisions, not just similar swatches. The first four exact user/agent collisions were absent in `dev`. Some older palettes already had perceptually close blue or orange role/accent families, so a blind reset to `dev` is insufficient. + +**Correction:** reserve distinct user and agent colors independently of interaction, status, compaction and Yolo colors. Check perceptual similarity as well as exact equality; keep text/icons readable on their actual rendered surfaces. + +### 2. Zed reference mapping is incomplete + +The added palettes reuse Zed surface/text values but do not preserve all of its role separation: + +| CodeNomad ID | Closest direct reference | Exact matching examples | +| --- | --- | --- | +| porcelain | One Light | editor `#FAFAFA`, panels `#EBEBEC`, text `#242529` | +| dawn | Ayu Light | editor `#FCFCFC`, panels `#ECECED`, border `#CFD1D2` | +| parchment | Gruvbox Light | editor `#FBF1C7`, text `#282828`, accent `#0B6678` | + +Some source colors were deliberately darkened for contrast, but that adaptation is not identified in the picker. More importantly, Zed models `element.selected` independently from `text.accent`: One Light uses `#CACACA` versus `#5C78E2`; Ayu Light uses `#CFD0D2` versus `#3B9EE5`. CodeNomad instead synthesizes selection from the accent for every palette. Zed also distinguishes window, panel and editor surfaces more explicitly than the shared derived formulas do. + +**Correction:** document the source and mapping for each palette, retain the reference surface character, and make selection independent where the reference requires it. Do not undo the validated tab geometry or restore bright tab fills as a workaround. + +### 3. Redundant choices, especially Custom + +- `dev` has nine catalog entries; the UI branch has twelve. These counts include System and Custom, not just distinct ready-made palettes. +- Basalt and default Custom share **all ten core colors**; only three of the four semantic fields differ. On `dev`, they were fully identical, so this duplication predates the latest changes. +- System Dark, Basalt and Fjord have very close large-area surfaces. Porcelain and Dawn have almost identical neutral surface brightness. The quieter tab and message treatment reduces their remaining visible differences further. +- Custom is unconditionally listed in `BUILT_IN_COLOR_SCHEMES` and the editor options. The inspected profile has default Custom selected with no named presets or overrides: this entry is not evidence of a user-created duplicate. + +**Correction:** present System as an automatic mode and customization as an action, not as additional near-duplicate palettes. Preserve named presets and legacy Custom colors when migrating; never delete user data merely because two palettes look similar. + +### 4. Classic editor changes can be saved but ignored — high priority + +The UI allows built-in overrides and saves them through `saveColorSchemeOverride()`. However, `applyColorScheme()` skips all derived properties whenever `id === "classic"`, including explicit overrides. + +Reproduction with the real normalizer/renderer and a mock target: set Classic's `accentPrimary` to `#FF00FF`; the renderer applies **zero properties** and falls back to static CSS. The exact default Classic rendering can remain protected without discarding an explicit saved override. + +### 5. Edited System colors can freeze across appearances + +System overrides store one color set, while its appearance remains `system`. The renderer prefers saved colors over its light/dark defaults. Supplying System Light colors and then resolving with `systemDark: true` still applies surface `#F7F8FA`. + +**Correction:** either maintain separate light/dark selections for automatic mode or save an edited System appearance as a named fixed palette. Do not silently create a mixed light-palette/dark-mode configuration. + +### 6. Current tests do not protect visual identity + +The 21 existing palette/preset tests pass. They cover schema, contrast and selected token assignments, but not user/agent collisions, catalog duplicates, Classic overrides, or System overrides across appearances. The editor and preset loader now accept syntactically valid colors without contrast validation. Loading should preserve user data; saving can offer non-destructive warnings instead of rejecting or deleting it. + +## Proposed light collection + +Start with six named, source-backed light palettes, rather than filling the picker with Soft/Medium/Hard variants of one family: + +| Reference | Intended character | +| --- | --- | +| One Light | neutral white/gray, clear separation between panels and content | +| Ayu Light | airy near-white, softer blue-gray text | +| Catppuccin Latte | cool gray/lavender, mauve accent | +| Solarized Light | ivory paper, blue-gray text | +| Gruvbox Light | visibly warm ochre paper and stronger text | +| Everforest Light | muted paper/olive family; choose one variant after contact-sheet comparison | + +Existing light IDs should have a deliberate compatibility mapping; additional entries should not overwrite saved colors. This is a proposed collection, not an assertion that all six are already implemented or visually accepted. + +## Validation gate for the correction pass + +1. Reproduce and test persistence/rendering defects before changing the catalog. +2. Keep reference metadata and role mapping together; retain necessary licenses/attribution if copying theme data. +3. Test role separation, contrast on actual surfaces, palette migration and independent light/dark automatic behavior. +4. Compare every candidate on the same conversation, sidebar, selected row, prompt and Status panel, in both normal and interaction states. +5. Test render after selection, editing, save, reload and reset. A correct swatch alone is not enough. +6. Validate in the isolated UI profile only; do not restart existing desktop hosts or the shared OpenCode service. + +## Implementation follow-up + +The findings above record the pre-change audit. The subsequent correction pass +is implemented in the UI worktree and included in PR #667's reconciliation: + +- Six soft light palettes: Mist, Slate, Clay, Linen, Iris and Sage. +- Four soft dark palettes: Mist, Slate, Clay and Sage. Their canvases are + mid-dark grays rather than the near-black surfaces of the legacy collection. +- The existing six dark palettes and original Light remain in an explicitly + separate **Original palettes** group. Their declared surface values from + `dev` are preserved; participant roles and the surface mapping are corrected. +- Transcript/message surfaces use the panel surface, not the composer canvas; + user messages regain a slight identity tint and tools use the third surface. +- Neutral selection is independent of user/agent identity. The new families + share separate, contrast-tested identity colors per appearance. +- System is a separate automatic-mode button, not another duplicate palette. + Editing it creates a named fixed palette; historical embedded System colors + are preserved as a fixed customization instead of mixed light/dark rendering. +- Default-only Custom is represented by Basalt without rewriting its stored + data. Named and genuinely edited custom palettes are kept. There is no empty + Custom entry in a clean picker; New remains directly available. +- Classic edits now reach the renderer. Deleting a named preset retains its + current colors and the swatches correctly reflect those retained colors. +- Palette selection awaits persistence, disables competing edits during the + write, and reports save failure rather than hiding it behind optimistic state. + +### Review iterations and validation + +The initial candidate colors were **not** accepted unchanged. The review +corrected weak muted-text contrast on tool surfaces, near-identical Mist/Slate +light surfaces, two dark agent/function collisions, and almost identical +primary/muted text in Linen and several other light palettes. + +- **69 targeted tests passed**: palette definitions/rendering/persistence + helpers/quality plus transcript visibility, timeline and drawer regressions. +- UI typecheck and production build passed; `git diff --check` passed. +- **17 actual native palette selections and screenshots** were taken in the + isolated profile, with separate transcript/composer backgrounds verified for + every choice. Tool-header hover and composer focus were exercised for each. +- The same screenshot sets were compared as contact sheets; full-size views + were also reviewed. The six new light families must differ on large-area + surfaces alone. Legacy palettes may retain close surfaces differentiated by + their established accent; they are not advertised as new soft variants. +- **14 native settings checks passed**: Classic save/reselect/reload/reset, + System light/dark changes, named creation/deletion/retained swatches and + 420/960px LTR/RTL layout. +- **8 independent Edge layout cases passed** with the compiled stylesheet: + fine/coarse pointer × LTR/RTL × 420/960px. Coarse-pointer controls measured + at least 40px. WebView2 touch emulation did not expose a coarse pointer, so + these touch measurements are not claimed as native touch-device testing. +- New labels and group names are present in all ten locales. + +New palette definitions and attribution are documented in +[`PALETTE_SOURCES.md`](./PALETTE_SOURCES.md). Test colors and the temporary named +test preset were not left in the catalog. Only the isolated build's UI assets +were refreshed; no desktop executable or shared OpenCode service was restarted. + +### Local visual evidence + +Artifacts under `%LOCALAPPDATA%/Temp/opencode/`: + +- `palette-native-review.json`, `palette-review-.png` +- `palette-sheet-soft-dark.png`, `palette-sheet-legacy-dark.png`, `palette-sheet-light.png` +- `palette-controls-native.json`, `palette-touch-report.json` +- `palette-final-tests.log`, `ui-palettes-build.log` + +## Source links + +- [Zed One](https://github.com/zed-industries/zed/blob/6f73c7d0a4aae8e32afb5d01b0fcb89e5e3642ff/assets/themes/one/one.json) +- [Zed Ayu](https://github.com/zed-industries/zed/blob/6f73c7d0a4aae8e32afb5d01b0fcb89e5e3642ff/assets/themes/ayu/ayu.json) +- [Zed Gruvbox](https://github.com/zed-industries/zed/blob/6f73c7d0a4aae8e32afb5d01b0fcb89e5e3642ff/assets/themes/gruvbox/gruvbox.json) +- [Catppuccin for Zed](https://github.com/catppuccin/zed/blob/main/themes/catppuccin-mauve.json) +- [Solarized for Zed](https://github.com/harmtemolder/Solarized.zed/blob/main/themes/solarized.json) +- [Everforest for Zed](https://github.com/albertsko/zed-everforest/blob/main/themes/everforest-regular.json) +- [Official Zed extension registry](https://github.com/zed-industries/extensions) diff --git a/dev-docs/PALETTE_SOURCES.md b/dev-docs/PALETTE_SOURCES.md new file mode 100644 index 000000000..d6db6aa52 --- /dev/null +++ b/dev-docs/PALETTE_SOURCES.md @@ -0,0 +1,74 @@ +# Soft palette sources and adaptation + +CodeNomad's soft collection uses its own localized names and semantic roles. +Appearance mode and the two saved palettes are independent. The picker is flat +and filters by fixed mode; Auto exposes both slots for editing. Classic retains +the dev canvas/panel/tool relationship, and all families use the muted surface +for message/tool cards with base-canvas inset output. Earlier review notes that +mention grouped palettes or panel-colored assistant cards are superseded. +These are adaptations, not exact editor-theme ports. No Zed syntax definitions, +theme loader, extension code, or complete theme JSON is shipped. + +| CodeNomad family | Light ID | Dark ID | Visual reference | +| --- | --- | --- | --- | +| Mist / Brume | `porcelain` | `mist` | Zed One Light / One Dark | +| Slate / Ardoise | `dawn` | `slate` | Zed Ayu Light / Ayu Mirage | +| Clay / Argile | `parchment` | `clay` | Zed Gruvbox, soft earth-gray surfaces | +| Linen / Lin | `linen` | — | Solarized's paper/gray-green relationship | +| Iris | `iris` | — | Catppuccin Latte's lavender-gray structure | +| Sage / Sauge | `sage-light` | `sage` | Everforest Soft's gray-green structure | + +The first three light IDs stay stable so saved selections and overrides remain +addressable. Existing Classic, Basalt, Fjord, Lichen, Velvet and Ember retain +their declared surface colors from `dev`. Their message surfaces are no longer +collapsed onto the composer background. Participant identity colors are shared +within each appearance, and selection uses a neutral surface tint rather than +borrowing a participant color. Custom colors are never silently recolored. + +The soft light canvases are darker than the original editor whites. Dark +canvases use mid-dark grays instead of near-black. Text is adjusted for at least +4.5:1 on base, panel, tool and user-message surfaces; participant colors are +checked at full opacity for at least 3:1. Decorative resting icon opacity is +unchanged and is not claimed to meet that full-opacity contrast threshold. + +## Reference revisions + +- Zed One, Ayu and Gruvbox: `zed-industries/zed` commit + `6f73c7d0a4aae8e32afb5d01b0fcb89e5e3642ff`, under `assets/themes/`. +- [Catppuccin for Zed](https://github.com/catppuccin/zed), reviewed 2026-09-08. +- [Everforest for Zed](https://github.com/albertsko/zed-everforest), reviewed 2026-09-08. +- [Original Solarized](https://github.com/altercation/solarized), MIT licensed. + The GPL Zed Solarized extension was inspected as a visual reference only; + none of its code, JSON or color values is included in Linen's definition. + +## Theme attribution and MIT notices + +The following MIT-licensed references informed the adaptation. Copyright +notices are retained here; the common permission and warranty text below +applies to each reference independently. + +- One: Copyright (c) 2014 GitHub Inc. +- Ayu: Copyright (c) 2016 Ike Ku +- Catppuccin: Copyright (c) 2021 Catppuccin +- Everforest Zed port: Copyright (c) 2025 Albert Skonieczny +- Solarized: Copyright (c) 2011 Ethan Schoonover +- Zed's Gruvbox `LICENSE` contains the literal placeholder notice: + `Copyright (c) `. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/dev-docs/SAVED_PALETTE_CALIBRATION_2026-09-11.md b/dev-docs/SAVED_PALETTE_CALIBRATION_2026-09-11.md new file mode 100644 index 000000000..39aaa6887 --- /dev/null +++ b/dev-docs/SAVED_PALETTE_CALIBRATION_2026-09-11.md @@ -0,0 +1,66 @@ +# Saved palette calibration — 2026-09-11 + +The user requested that built-in defaults reproduce their saved palette edits. +The 14 built-in overrides were read from the active profile before removing the +unrelated legacy Custom entry and the named “Iris 2” experiment from that profile. +Iris uses its saved built-in override, not the Iris 2 experiment. Stable palette +IDs, appearance slots and the other named presets remain supported. + +## Changed defaults + +| Palette ID | Saved changes | +| --- | --- | +| classic | accentPrimary `#4D7AFE` | +| mist | accentPrimary `#D0ED9C`, statusSuccess `#81C1A8` | +| slate | textMuted `#9DB5D2` | +| clay | surfaceSecondary `#41403E`, surfaceMuted `#55524E`, accentPrimary `#87BC5C`, statusSuccess `#B9BF69`, compactionAccent `#AA91FD` | +| sage | statusSuccess `#81C182` | +| fjord | statusSuccess `#8FC473` | +| ember | accentPrimary `#D99254` | +| porcelain | compactionAccent `#A07AFF` | +| dawn | accentPrimary `#7F69E2` | +| parchment | accentPrimary `#A9BA45` | +| linen | accentPrimary `#9D8325`, statusSuccess `#699245` | +| iris | accentPrimary `#6B7CFF` | +| sage-light | accentPrimary `#6C7FCB` | +| basalt | userAccent `#88EEFB` | + +Other saved fields are retained exactly, including the historical `yoloAccent` +field; rendered YOLO already follows `accentPrimary`. + +## Quality baseline + +These are exact saved choices, not contrast-corrected approximations. The strict +`validateColorSchemeColors` helper is unchanged. Its expected exceptions are +Porcelain, Dawn, Slate, Parchment, Clay, Linen, Iris and Sage Light. Primary text +still meets 4.5:1 on all message/tool surfaces. The targeted quality checks bound +Slate secondary text at 3.99:1, Clay secondary text at 4.02:1 and Porcelain's +compaction accent at 1.72:1; other identity colors retain the 3:1 check. The saved +Clay/Sage surface distance is 2.24 OKLab units (bounded at 2.2 rather than 2.5). + +## Settings layout + +Language comes first. A single Auto/Dark/Light control sets appearance; Auto's +palette picker follows the resolved system appearance and retains both saved +selections. A dirty draft stays on its original appearance until saved/discarded. +The palette picker and actions form one column beside a 7-column, two-row swatch +grid. Color wells are half their former height (1.375rem); labels remain clickable. +The layout stacks according to available card width rather than window width. + +## Validation and gatekeeper review + +- UI typecheck and production build passed. +- 60 palette/theme tests passed. Emphasis exceptions are scoped to exact + palette/field/foreground/background tuples; all other emphasis pairs keep 3:1. +- Both appearance browser tests passed, covering Auto/system changes, persistence + and the 17-palette local-hover matrix. +- All five tab browser tests passed, including isolated Electron native zoom. +- 24 layout combinations passed: English/French/Hebrew, 320/375/640/1100px, + light/dark. Two swatch rows, 22px wells and no horizontal overflow. +- The active Tauri profile was checked after restart: both unwanted entries are + absent, Auto has one mode selector, and the light/dark selections are retained. +- Gatekeeper round 1 found a test-protection regression: palette-wide contrast + exemptions could hide an unrelated status-color regression. Fixed with exact + pair exemptions and checks for all eight emphasis fields. +- Gatekeeper round 2: **PASS, no actionable findings**. Its in-memory mutation of + Slate's error color to its base surface now fails the expected 1:1 contrast check. diff --git a/dev-docs/TAB_CHROME_REVIEW_2026-09-10.md b/dev-docs/TAB_CHROME_REVIEW_2026-09-10.md new file mode 100644 index 000000000..f035b4b19 --- /dev/null +++ b/dev-docs/TAB_CHROME_REVIEW_2026-09-10.md @@ -0,0 +1,68 @@ +# Tab chrome and zoom review — 2026-09-10 + +## Cause and correction + +Project and right-panel tab strips overrode the shared thin scrollbar with +`scrollbar-width: auto` and forced `scrollbar-color: auto`. Two `scaleY(-1)` +transforms moved that scrollbar to the top and restored the text orientation. +Negative margins, extra one-pixel heights and active-tab cover pseudo-elements +then attempted to reconnect the strip to its panel. These independently rounded +and composited layers made fractional zoom fragile. + +Both strips now use `components/tab-scroll.tsx`. A native scrollbar above the +upright tab viewport inherits the common scrollbar style. Scroll offsets are +synchronized in both directions, including negative RTL offsets. ResizeObserver +updates the scrollbar extent as tabs or the available width change. The viewport +retains native wheel/touch and focus scrolling; no custom thumb is painted. + +Borders no longer overlap with negative margins or extend via an extra +active-tab pseudo-element. Each adjoining edge has one owner. The strip baseline +is painted on its container and the active tab covers it in normal layout. +Permanent `will-change: transform` is removed; actual drag transforms remain. + +## Validation + +- Real `InstanceTabs`, `InstanceTab`, and `RightPanel` in deterministic browser + fixtures; no calls to a user's backend from these fixtures. +- Edge: 80/90/100/110/125/150% CSS zoom, DPR 1/1.25/1.5/2, LTR and RTL. +- Electron 39: real isolated BrowserWindow using native + `webContents.setZoomFactor`, the same zoom range, LTR/RTL, Classic dark/light. +- Tauri: separate copy of the existing release executable/resources, a new + configuration profile, and the rebuilt UI. Native WebView2 zoom via + `plugin:webview|set_webview_zoom`, the same zoom range, LTR/RTL: 12 cases passed. + No existing CodeNomad process or shared OpenCode daemon was stopped/restarted. +- Assert adjoining tab edges, baseline alignment, shared scrollbar style, + scroll-range parity, synchronization, no overflow for a short tab list, + native wheel and keyboard selection/reveal, and resize behavior. +- Full browser suite: 29 tests passed before adding the final sidecar, + pointer-reorder and touch cases. UI and Electron typechecks and UI build passed. +- Final focused suite: all 5 tests passed, including sidecar selection, native + pointer reordering and touch layout. The matrix includes 48 zoom/DPI/direction + cases in Edge and 24 native zoom/palette/direction cases in Electron. + +Local screenshots are in `%LOCALAPPDATA%/Temp/opencode/pr667-tab-zoom/`, including +native Tauri images and `tauri-native-report.json`. Browser/Electron fixtures +close only their own test windows. The isolated Tauri test host is left open. + +Optional Electron run: + +```powershell +$env:CODENOMAD_TEST_ELECTRON = "$PWD/node_modules/electron/dist/electron.exe" +$env:CODENOMAD_BROWSER_PATH = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' +node --import tsx --test packages/ui/tests/browser/tab-chrome.test.ts +``` + +`CODENOMAD_TAB_SCREENSHOTS` optionally saves the capture matrix. +`CODENOMAD_TEST_TEMP` optionally selects the Electron test-profile parent. + +## Follow-up: phantom overflow at fractional zoom + +The first implementation copied integer `scrollWidth` into the scrollbar extent. +At fractional zoom that could make a fitting lane fractionally wider than its +viewport. A fitting extent now remains `100%` wide with horizontal overflow +hidden; genuinely overflowing content keeps its computed fractional CSS width. +The fitting-tab assertion now runs at every zoom/DPI/direction/palette combination +in Edge and Electron, not just at the end of each series. All five focused tests, +UI typecheck and production build passed again. The earlier Tauri test host was +already closed, so its previous native capture matrix does not validate this +follow-up; no user window was reloaded for that check. diff --git a/dev-docs/ui-harmonization-demo/01-classic.png b/dev-docs/ui-harmonization-demo/01-classic.png new file mode 100644 index 000000000..f452385d8 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/01-classic.png differ diff --git a/dev-docs/ui-harmonization-demo/02-mist.png b/dev-docs/ui-harmonization-demo/02-mist.png new file mode 100644 index 000000000..537e05ce5 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/02-mist.png differ diff --git a/dev-docs/ui-harmonization-demo/03-slate.png b/dev-docs/ui-harmonization-demo/03-slate.png new file mode 100644 index 000000000..26e4bb069 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/03-slate.png differ diff --git a/dev-docs/ui-harmonization-demo/04-clay.png b/dev-docs/ui-harmonization-demo/04-clay.png new file mode 100644 index 000000000..e3a4226e4 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/04-clay.png differ diff --git a/dev-docs/ui-harmonization-demo/05-sage.png b/dev-docs/ui-harmonization-demo/05-sage.png new file mode 100644 index 000000000..954ce25cc Binary files /dev/null and b/dev-docs/ui-harmonization-demo/05-sage.png differ diff --git a/dev-docs/ui-harmonization-demo/06-basalt.png b/dev-docs/ui-harmonization-demo/06-basalt.png new file mode 100644 index 000000000..fb523ee68 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/06-basalt.png differ diff --git a/dev-docs/ui-harmonization-demo/07-fjord.png b/dev-docs/ui-harmonization-demo/07-fjord.png new file mode 100644 index 000000000..d7b2fdb07 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/07-fjord.png differ diff --git a/dev-docs/ui-harmonization-demo/08-lichen.png b/dev-docs/ui-harmonization-demo/08-lichen.png new file mode 100644 index 000000000..684d5e298 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/08-lichen.png differ diff --git a/dev-docs/ui-harmonization-demo/09-velvet.png b/dev-docs/ui-harmonization-demo/09-velvet.png new file mode 100644 index 000000000..cb810684c Binary files /dev/null and b/dev-docs/ui-harmonization-demo/09-velvet.png differ diff --git a/dev-docs/ui-harmonization-demo/10-ember.png b/dev-docs/ui-harmonization-demo/10-ember.png new file mode 100644 index 000000000..9bd51975a Binary files /dev/null and b/dev-docs/ui-harmonization-demo/10-ember.png differ diff --git a/dev-docs/ui-harmonization-demo/11-light.png b/dev-docs/ui-harmonization-demo/11-light.png new file mode 100644 index 000000000..bf3f770d7 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/11-light.png differ diff --git a/dev-docs/ui-harmonization-demo/12-porcelain.png b/dev-docs/ui-harmonization-demo/12-porcelain.png new file mode 100644 index 000000000..264b39d5e Binary files /dev/null and b/dev-docs/ui-harmonization-demo/12-porcelain.png differ diff --git a/dev-docs/ui-harmonization-demo/13-dawn.png b/dev-docs/ui-harmonization-demo/13-dawn.png new file mode 100644 index 000000000..75b9f14be Binary files /dev/null and b/dev-docs/ui-harmonization-demo/13-dawn.png differ diff --git a/dev-docs/ui-harmonization-demo/14-parchment.png b/dev-docs/ui-harmonization-demo/14-parchment.png new file mode 100644 index 000000000..f62f9d5b3 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/14-parchment.png differ diff --git a/dev-docs/ui-harmonization-demo/15-linen.png b/dev-docs/ui-harmonization-demo/15-linen.png new file mode 100644 index 000000000..96af739ee Binary files /dev/null and b/dev-docs/ui-harmonization-demo/15-linen.png differ diff --git a/dev-docs/ui-harmonization-demo/16-iris.png b/dev-docs/ui-harmonization-demo/16-iris.png new file mode 100644 index 000000000..563bffec4 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/16-iris.png differ diff --git a/dev-docs/ui-harmonization-demo/17-sage-light.png b/dev-docs/ui-harmonization-demo/17-sage-light.png new file mode 100644 index 000000000..59e44ddb6 Binary files /dev/null and b/dev-docs/ui-harmonization-demo/17-sage-light.png differ diff --git a/dev-docs/ui-harmonization-demo/README.md b/dev-docs/ui-harmonization-demo/README.md new file mode 100644 index 000000000..e43bc933f --- /dev/null +++ b/dev-docs/ui-harmonization-demo/README.md @@ -0,0 +1,27 @@ +# UI Harmonisation — palette gallery + +17 built-in palettes, captured on **12 September 2026** from the native Tauri +UI Harmonisation build with UI resources at `bfc07ecd`. English interface, +five projects open; existing conversations retain their original language. + +Click a preview for the original **1584 × 1031 PNG**. +[Offline gallery](index.html) · [Capture metadata](manifest.json) + +## Dark + +| | | | +| --- | --- | --- | +| **Classic**
[Classic dark](01-classic.png) | **Mist**
[Mist dark](02-mist.png) | **Slate**
[Slate dark](03-slate.png) | +| **Clay**
[Clay dark](04-clay.png) | **Sage**
[Sage dark](05-sage.png) | **Basalt**
[Basalt](06-basalt.png) | +| **Fjord**
[Fjord](07-fjord.png) | **Lichen**
[Lichen](08-lichen.png) | **Velvet**
[Velvet](09-velvet.png) | +| **Ember**
[Ember](10-ember.png) | | | + +## Light + +| | | | +| --- | --- | --- | +| **Classic**
[Classic light](11-light.png) | **Mist**
[Mist light](12-porcelain.png) | **Slate**
[Slate light](13-dawn.png) | +| **Clay**
[Clay light](14-parchment.png) | **Linen**
[Linen](15-linen.png) | **Iris**
[Iris](16-iris.png) | +| **Sage**
[Sage light](17-sage-light.png) | | | + +These are unretouched workspace captures, not additional test results. diff --git a/dev-docs/ui-harmonization-demo/index.html b/dev-docs/ui-harmonization-demo/index.html new file mode 100644 index 000000000..103afa343 --- /dev/null +++ b/dev-docs/ui-harmonization-demo/index.html @@ -0,0 +1,50 @@ + + + + +CodeNomad — palette gallery + +
+

CodeNomad · UI Harmonisation

+

17 palettes · English interface · five projects · native Tauri · 12 September 2026

+

Click a preview for the original 1584 × 1031 image. Capture details

+
+
+

Dark

+
+
Classic dark
Classic
+
Mist dark
Mist
+
Slate dark
Slate
+
Clay dark
Clay
+
Sage dark
Sage
+
Basalt
Basalt
+
Fjord
Fjord
+
Lichen
Lichen
+
Velvet
Velvet
+
Ember
Ember
+
+

Light

+
+
Classic light
Classic
+
Mist light
Mist
+
Slate light
Slate
+
Clay light
Clay
+
Linen
Linen
+
Iris
Iris
+
Sage light
Sage
+
+
+ diff --git a/dev-docs/ui-harmonization-demo/manifest.json b/dev-docs/ui-harmonization-demo/manifest.json new file mode 100644 index 000000000..8e2d7d7d3 --- /dev/null +++ b/dev-docs/ui-harmonization-demo/manifest.json @@ -0,0 +1,240 @@ +[ + { + "index": 1, + "id": "classic", + "name": "Classic", + "appearance": "dark", + "file": "01-classic.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:04:48.053Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 2, + "id": "mist", + "name": "Mist", + "appearance": "dark", + "file": "02-mist.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:04:45.492Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 3, + "id": "slate", + "name": "Slate", + "appearance": "dark", + "file": "03-slate.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:03:35.135Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 4, + "id": "clay", + "name": "Clay", + "appearance": "dark", + "file": "04-clay.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:04:51.800Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 5, + "id": "sage", + "name": "Sage", + "appearance": "dark", + "file": "05-sage.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:04:56.464Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 6, + "id": "basalt", + "name": "Basalt", + "appearance": "dark", + "file": "06-basalt.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:05:00.371Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 7, + "id": "fjord", + "name": "Fjord", + "appearance": "dark", + "file": "07-fjord.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:10:14.842Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 8, + "id": "lichen", + "name": "Lichen", + "appearance": "dark", + "file": "08-lichen.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:06:05.896Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 9, + "id": "velvet", + "name": "Velvet", + "appearance": "dark", + "file": "09-velvet.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:06:10.799Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 10, + "id": "ember", + "name": "Ember", + "appearance": "dark", + "file": "10-ember.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:06:14.809Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 11, + "id": "light", + "name": "Classic", + "appearance": "light", + "file": "11-light.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:06:18.706Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 12, + "id": "porcelain", + "name": "Mist", + "appearance": "light", + "file": "12-porcelain.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:06:22.840Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 13, + "id": "dawn", + "name": "Slate", + "appearance": "light", + "file": "13-dawn.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:06:26.683Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 14, + "id": "parchment", + "name": "Clay", + "appearance": "light", + "file": "14-parchment.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:09:04.504Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 15, + "id": "linen", + "name": "Linen", + "appearance": "light", + "file": "15-linen.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:10:18.640Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 16, + "id": "iris", + "name": "Iris", + "appearance": "light", + "file": "16-iris.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:10:13.109Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + }, + { + "index": 17, + "id": "sage-light", + "name": "Sage", + "appearance": "light", + "file": "17-sage-light.png", + "window": "workspace", + "size": { + "width": 1584, + "height": 1031 + }, + "capturedAt": "2026-09-12T14:09:16.305Z", + "sourceCommit": "bfc07ecd89d75bb943590a18869658a574aced70" + } +] \ No newline at end of file diff --git a/packages/electron-app/electron/main/client-state-process-identity.ts b/packages/electron-app/electron/main/client-state-process-identity.ts index 749bfc57e..752d5cddb 100644 --- a/packages/electron-app/electron/main/client-state-process-identity.ts +++ b/packages/electron-app/electron/main/client-state-process-identity.ts @@ -63,7 +63,7 @@ export function getProcessStartIdentity(pid: number): string | undefined { "-NoProfile", "-NonInteractive", "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, ], "win32", ) @@ -99,7 +99,7 @@ export async function getProcessStartIdentityAsync( "-NoProfile", "-NonInteractive", "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, ], "win32", timeoutMs) } } catch { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 278d199fa..4d4953291 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -423,7 +423,11 @@ function runPrimary(firstIntent: LaunchIntent) { }) } - async function openPreferences(request: PreferencesRequest): Promise { + async function openPreferences(request: PreferencesRequest, toggle = false): Promise { + if (toggle && preferencesWindows.current()) { + preferencesWindows.current()?.close() + return + } if (preferencesWindows.reuse(request)) { await clientState.setPreferences(request) return diff --git a/packages/electron-app/electron/main/preferences-ipc.test.ts b/packages/electron-app/electron/main/preferences-ipc.test.ts index 4bd6b9a93..2ef5d80fa 100644 --- a/packages/electron-app/electron/main/preferences-ipc.test.ts +++ b/packages/electron-app/electron/main/preferences-ipc.test.ts @@ -30,7 +30,7 @@ function harness() { resolveLocal: (sender) => sender === localContents ? { window: localWindow } : undefined, resolvePreferences: (sender) => sender === preferencesContents ? preferencesWindow : undefined, getAllowedOrigins: () => ["http://localhost:3000"], - openPreferences: async (request) => { calls.push(`open:${request.section}:${request.instanceId ?? ""}`) }, + openPreferences: async (request, toggle) => { calls.push(`open:${request.section}:${request.instanceId ?? ""}:${Boolean(toggle)}`) }, getRequest: () => ({ section: "speech" }), markReady: () => { calls.push("ready") }, acceptRequest: (_window, request) => { calls.push(`accept:${request.section}`) }, @@ -48,7 +48,7 @@ test("Preferences IPC separates local open authority and controls registered app "preferences:open", "preferences:getSection", "preferences:ready", "preferences:acceptRequest", "preferences:resolveTransition", "preferences:minimize", "preferences:toggleMaximize", "preferences:close", ]) - assert.deepEqual(await h.handlers.get("preferences:open")!(h.event(h.localContents), "speech", { instanceId: "workspace-1" }), { ok: true }) + assert.deepEqual(await h.handlers.get("preferences:open")!(h.event(h.localContents), "speech", { instanceId: "workspace-1" }, true), { ok: true }) await assert.rejects(h.handlers.get("preferences:open")!(h.event(h.preferencesContents), "speech"), /local window/) await assert.rejects(h.handlers.get("preferences:open")!(h.event(h.localContents), "workspace"), /Invalid preferences section/) @@ -61,7 +61,7 @@ test("Preferences IPC separates local open authority and controls registered app assert.deepEqual(h.handlers.get("preferences:toggleMaximize")!(h.event(h.preferencesContents)), { maximized: true }) assert.deepEqual(h.handlers.get("preferences:toggleMaximize")!(h.event(h.preferencesContents)), { maximized: false }) assert.deepEqual(await h.handlers.get("preferences:close")!(h.event(h.preferencesContents)), { ok: true }) - assert.deepEqual(h.calls, ["open:speech:workspace-1", "ready", "accept:providers", "transition:3:false", "minimize", "local:minimize", "maximize", "unmaximize", "approve", "close"]) + assert.deepEqual(h.calls, ["open:speech:workspace-1:true", "ready", "accept:providers", "transition:3:false", "minimize", "local:minimize", "maximize", "unmaximize", "approve", "close"]) }) test("Preferences IPC rejects unregistered, subframe, and cross-origin senders", () => { diff --git a/packages/electron-app/electron/main/preferences-ipc.ts b/packages/electron-app/electron/main/preferences-ipc.ts index 7bbefd36e..607acb19e 100644 --- a/packages/electron-app/electron/main/preferences-ipc.ts +++ b/packages/electron-app/electron/main/preferences-ipc.ts @@ -10,7 +10,7 @@ interface PreferencesIPCDependencies { resolveLocal(sender: IpcMainInvokeEvent["sender"]): { window: BrowserWindow } | undefined resolvePreferences(sender: IpcMainInvokeEvent["sender"]): BrowserWindow | undefined getAllowedOrigins(window: BrowserWindow): string[] - openPreferences(request: PreferencesRequest): Promise + openPreferences(request: PreferencesRequest, toggle?: boolean): Promise getRequest(window: BrowserWindow): PreferencesRequest | undefined markReady(window: BrowserWindow): void acceptRequest(window: BrowserWindow, request: PreferencesRequest): void | Promise @@ -39,9 +39,10 @@ export function setupPreferencesIPC(ipcMain: IPCRegistrar, dependencies: Prefere return window } - ipcMain.handle("preferences:open", async (event, section: unknown, context: unknown) => { + ipcMain.handle("preferences:open", async (event, section: unknown, context: unknown, toggle: unknown) => { local(event) - await dependencies.openPreferences(requirePreferencesRequest(section, context)) + if (typeof toggle !== "undefined" && typeof toggle !== "boolean") throw new Error("Invalid Preferences toggle") + await dependencies.openPreferences(requirePreferencesRequest(section, context), toggle === true) return { ok: true } }) ipcMain.handle("preferences:getSection", (event) => { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index c5c41c9ec..36c7a2ec2 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -59,7 +59,7 @@ const localElectronAPI = { setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), - openPreferences: (section, context) => ipcRenderer.invoke("preferences:open", section, context), + openPreferences: (section, context, toggle) => ipcRenderer.invoke("preferences:open", section, context, Boolean(toggle)), minimizeWindow: () => ipcRenderer.invoke("preferences:minimize"), toggleMaximizeWindow: () => ipcRenderer.invoke("preferences:toggleMaximize"), closeWindow: () => ipcRenderer.invoke("preferences:close"), diff --git a/packages/server/src/opencode/automation-plugin.test.ts b/packages/server/src/opencode/automation-plugin.test.ts index 495951c96..4cde12c2c 100644 --- a/packages/server/src/opencode/automation-plugin.test.ts +++ b/packages/server/src/opencode/automation-plugin.test.ts @@ -6,6 +6,7 @@ import path from "node:path" import test from "node:test" import { AUTOMATION_BRIDGE_PATH, + automationBridgeDirectory, automationBridgeDirectories, createAutomationBridgeRegistration, parseDeveloperAction, @@ -50,6 +51,23 @@ function closeServer(server: http.Server | undefined): Promise { return new Promise((resolve) => server?.close(() => resolve()) ?? resolve()) } +function isolateAutomationBridgeRegistry(root: string): () => void { + const previousLocalAppData = process.env.LOCALAPPDATA + const previousXdgRuntimeDir = process.env.XDG_RUNTIME_DIR + const previousWslDistroName = process.env.WSL_DISTRO_NAME + process.env.LOCALAPPDATA = root + process.env.XDG_RUNTIME_DIR = root + delete process.env.WSL_DISTRO_NAME + return () => { + if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA + else process.env.LOCALAPPDATA = previousLocalAppData + if (previousXdgRuntimeDir === undefined) delete process.env.XDG_RUNTIME_DIR + else process.env.XDG_RUNTIME_DIR = previousXdgRuntimeDir + if (previousWslDistroName === undefined) delete process.env.WSL_DISTRO_NAME + else process.env.WSL_DISTRO_NAME = previousWslDistroName + } +} + test("validates Developer Mode actions", () => { assert.deepEqual(parseDeveloperAction({ action: "type", ref: "e4", text: "CodeNomad" }), { action: "type", @@ -100,8 +118,7 @@ test("removes only the generated legacy global plugin shim", async () => { test("restart waits for a new native generation and returns a fresh inspection", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-restart-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) const definitions: ToolDefinition[] = [] let removeOld: (() => Promise) | undefined let removeNew: (() => Promise) | undefined @@ -162,16 +179,14 @@ test("restart waits for a new native generation and returns a fresh inspection", await closeServer(newServer) await closeServer(preexistingServer) await Promise.all(distractorServers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) test("keeps inspected targets isolated per plugin setup", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-isolation-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -190,16 +205,14 @@ test("keeps inspected targets isolated per plugin setup", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) test("pins parallel sessions to their independently inspected bridges", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-sessions-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) const removals: Array<() => Promise> = [] const servers: http.Server[] = [] try { @@ -220,20 +233,14 @@ test("pins parallel sessions to their independently inspected bridges", async () } finally { await Promise.all(removals.map((remove) => remove())) await Promise.all(servers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) test("prunes stale registry pressure before limiting discovery", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-stale-")) - const previousLocalAppData = process.env.LOCALAPPDATA - const previousXdgRuntimeDir = process.env.XDG_RUNTIME_DIR - const previousWslDistroName = process.env.WSL_DISTRO_NAME - process.env.LOCALAPPDATA = root - process.env.XDG_RUNTIME_DIR = root - delete process.env.WSL_DISTRO_NAME + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -242,7 +249,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { : { result: { target: { id: "live", title: "Live", url: "http://app.test" }, nodes: [], diagnostics: [] } }) server = bridge.server removeBridge = await publishAutomationBridge(createAutomationBridgeRegistration(bridge.url)) - const directory = automationBridgeDirectories()[0] + const directory = automationBridgeDirectory() assert.equal(path.dirname(path.dirname(directory)), root) const base = Date.now() + 10_000 for (let index = 0; index < 70; index += 1) { @@ -262,12 +269,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData - if (previousXdgRuntimeDir === undefined) delete process.env.XDG_RUNTIME_DIR - else process.env.XDG_RUNTIME_DIR = previousXdgRuntimeDir - if (previousWslDistroName === undefined) delete process.env.WSL_DISTRO_NAME - else process.env.WSL_DISTRO_NAME = previousWslDistroName + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 763c47634..563671d1a 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -130,7 +130,7 @@ async function harness( return canonical.includes("/worktree") ? "workspace:worktree" : "workspace:root" }, getServicePathForPath: async (_id, candidate) => { - assert.ok(pathOwnershipChecks.includes(candidate), "prompt path must be ownership-checked before translation") + assert.ok(pathOwnershipChecks.includes(candidate), "path must be ownership-checked before translation") servicePathCalls.push(candidate) return pathMappings[candidate] ?? candidate }, @@ -203,8 +203,13 @@ describe("instance proxy location enforcement", () => { method: "GET", url: "/workspaces/workspace/instance/api/session?directory=%2Fother", }) + const activationResponse = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/plugin/await-activation?location%5Bdirectory%5D=%2Fother", + }) assert.equal(bodyResponse.statusCode, 403) assert.equal(queryResponse.statusCode, 403) + assert.equal(activationResponse.statusCode, 403) assert.equal(requestCount(), 0) assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) @@ -708,6 +713,9 @@ describe("instance proxy location enforcement", () => { })).statusCode, 403) assert.equal((await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/permission/saved" })).statusCode, 403) assert.equal((await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/permission/saved/global-rule" })).statusCode, 403) + for (const route of ["plugin/check", "plugin/update", "rpc/plugin/method"]) { + assert.equal((await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}` })).statusCode, 403) + } assert.equal(requestCount(), 0) }) @@ -723,6 +731,7 @@ describe("instance proxy location enforcement", () => { ["GET", "/workspaces/workspace/instance/api/reference"], ["GET", "/workspaces/workspace/instance/api/mcp/resource"], ["GET", "/workspaces/workspace/instance/api/websearch/provider"], + ["POST", "/workspaces/workspace/instance/api/plugin/await-activation"], ["DELETE", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1"], ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/steer"], ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/queue"], @@ -795,6 +804,59 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 1) }) + it("bounds filesystem list targets to owned worktrees before translating them", async () => { + const mappings = { + "/repo/sibling": "/home/dev/repo/sibling", + "/repo/worktree/src": "/home/dev/worktree/src", + } + const { app, servicePathCalls, requestCount } = await harness( + "/repo/worktree", + {}, + {}, + "/repo", + "/repo", + mappings, + ) + + const owned = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?location%5Bdirectory%5D=%2Frepo%2Fworktree&path=src", + }) + assert.equal(owned.statusCode, 200) + const upstreamUrl = new URL(JSON.parse(owned.body).url, "http://upstream") + assert.equal(upstreamUrl.pathname, "/api/fs/list") + assert.equal(upstreamUrl.searchParams.get("location[directory]"), "/repo/worktree") + assert.equal(upstreamUrl.searchParams.get("path"), "/home/dev/worktree/src") + assert.deepEqual(servicePathCalls, ["/repo/worktree/src"]) + assert.equal(requestCount(), 1) + + const sibling = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?location%5Bdirectory%5D=%2Frepo%2Fworktree&path=..%2Fsibling", + }) + assert.equal(sibling.statusCode, 200) + assert.equal(new URL(JSON.parse(sibling.body).url, "http://upstream").searchParams.get("path"), "/home/dev/repo/sibling") + + for (const pathValue of ["../../../other", "/other"]) { + const foreign = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/fs/list?path=${encodeURIComponent(pathValue)}`, + }) + assert.equal(foreign.statusCode, 403) + } + const duplicate = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?path=src&path=test", + }) + assert.equal(duplicate.statusCode, 400) + const nul = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?path=src%00secret", + }) + assert.equal(nul.statusCode, 400) + assert.equal(requestCount(), 2) + }) + it("defaults and validates only schema-defined imported session locations", async () => { const { app, requestCount } = await harness() const accepted = await app.inject({ diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 7a79f3351..30e8b0b46 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -763,6 +763,25 @@ async function proxyWorkspaceRequest(args: { } translatedDirectories.set(directory, translated) } + const fileListPath = readFileListPath(targetUrl, request.method, requestLocations.directories[0] ?? workspace.path) + if (fileListPath.invalid) { + reply.code(400).send({ error: "Invalid filesystem path" }) + return + } + if (fileListPath.candidate) { + if (!(await workspaceManager.ownsPath(workspaceId, fileListPath.candidate))) { + reply.code(403).send({ error: "Filesystem path does not belong to workspace" }) + return + } + const translated = workspaceManager.getServicePathForPath + ? await workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate) + : fileListPath.candidate + if (!translated) { + reply.code(403).send({ error: "Filesystem path does not belong to workspace" }) + return + } + targetUrl.searchParams.set("path", translated) + } const mutationIdentities = new Set() if (request.method !== "GET" && request.method !== "HEAD") { for (const directory of requestLocations.directories) { @@ -1014,6 +1033,30 @@ function readNativeCwd( else locations.invalid = true } +function readFileListPath( + targetUrl: URL, + method: string, + defaultDirectory: string, +): { candidate?: string; invalid: boolean } { + if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/fs/list") { + return { invalid: false } + } + const values = targetUrl.searchParams.getAll("path") + if (values.length > 1) return { invalid: true } + const requested = values[0]?.trim() || "." + if (requested.includes("\0")) return { invalid: true } + const windowsBase = /^[A-Za-z]:[\\/]/.test(defaultDirectory) || /^[\\/]{2}[^\\/]/.test(defaultDirectory) + if (!windowsBase && path.win32.isAbsolute(requested) && !path.posix.isAbsolute(requested)) { + return { candidate: path.win32.normalize(requested), invalid: false } + } + return { + candidate: windowsBase + ? path.win32.resolve(defaultDirectory, requested) + : path.posix.resolve(defaultDirectory, requested), + invalid: false, + } +} + function sanitizeInstanceProxyRequestHeaders( headers: Record, authorization: string | undefined, @@ -1245,6 +1288,7 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider|reference|skill)$/], + ["POST", /^\/api\/plugin\/await-activation$/], ["GET", /^\/api\/(?:mcp\/resource|websearch\/provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], diff --git a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs index 749d60960..c68b10ad5 100644 --- a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs +++ b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs @@ -924,7 +924,7 @@ fn process_start_identity(pid: u32) -> Option { "-NoProfile", "-NonInteractive", "-Command", - &format!("(Get-CimInstance Win32_Process -Filter \"ProcessId = {pid}\" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks"), + &format!("(Get-Process -Id {pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks"), ], ) .map(|value| format!("win32:{value}")) diff --git a/packages/tauri-app/src-tauri/src/preferences_window.rs b/packages/tauri-app/src-tauri/src/preferences_window.rs index 5c9e36738..aff36c1fb 100644 --- a/packages/tauri-app/src-tauri/src/preferences_window.rs +++ b/packages/tauri-app/src-tauri/src/preferences_window.rs @@ -209,9 +209,16 @@ pub(crate) async fn open_preferences_window( app_state: tauri::State<'_, AppState>, preferences: tauri::State<'_, PreferencesWindow>, request: PreferencesRequest, + toggle: Option, ) -> Result<(), String> { crate::require_local_app_window(&window, &app_state)?; - open_preferences(&app, &app_state, &preferences, request) + open_preferences( + &app, + &app_state, + &preferences, + request, + toggle.unwrap_or(false), + ) } fn open_preferences( @@ -219,6 +226,7 @@ fn open_preferences( app_state: &AppState, preferences: &PreferencesWindow, request: PreferencesRequest, + toggle: bool, ) -> Result<(), String> { let request = validate_request(request)?; let _operation = preferences @@ -226,6 +234,10 @@ fn open_preferences( .lock() .unwrap_or_else(|error| error.into_inner()); if let Some(existing) = app.get_webview_window(LABEL) { + if toggle { + existing.close().map_err(|error| error.to_string())?; + return Ok(()); + } app.state::() .set_preferences(Some(request.clone()))?; let renderer_ready = preferences @@ -312,7 +324,7 @@ pub(crate) fn navigate_backend(app: &AppHandle) { { let app_state = app.state::(); let preferences = app.state::(); - if let Err(error) = open_preferences(app, &app_state, &preferences, request) { + if let Err(error) = open_preferences(app, &app_state, &preferences, request, false) { eprintln!("[tauri] failed to restore preferences window: {error}"); } } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 0d0b0b888..ebf1032c7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -699,13 +699,13 @@ const App: Component = () => {
-
+

{t("app.launchError.binaryPathLabel")}

{launchErrorPath()}

-
+

{t("app.launchError.errorOutputLabel")}

{launchErrorMessage()}
diff --git a/packages/ui/src/components/action-overflow-menu.tsx b/packages/ui/src/components/action-overflow-menu.tsx index b83af45df..d05bc8ae0 100644 --- a/packages/ui/src/components/action-overflow-menu.tsx +++ b/packages/ui/src/components/action-overflow-menu.tsx @@ -18,6 +18,7 @@ interface ActionOverflowMenuProps { label: string triggerClass?: string minItems?: number + onOpenChange?: (open: boolean) => void } export default function ActionOverflowMenu(props: ActionOverflowMenuProps) { @@ -35,7 +36,10 @@ export default function ActionOverflowMenu(props: ActionOverflowMenuProps) { return ( - { if (!open) clearHoveredItem() }}> + { + if (!open) clearHoveredItem() + props.onOpenChange?.(open) + }}> findAgentById(availableAgents(), props.currentAgent)) + const accessibleLabel = () => t("agentSelector.trigger.primary", { agent: selectedAgent()?.name || t("agentSelector.none") }) const [isOpen, setIsOpen] = createSignal(false) let searchInputRef: HTMLInputElement | undefined @@ -95,7 +96,7 @@ export default function AgentSelector(props: AgentSelectorProps) { > - +
diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 20c8b9899..5f3897917 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -119,7 +119,7 @@ const AlertDialog: Component = () => {
= (props) => { -
+
{props.lockedBaseLabel}
void handleGo(event)}> = (props) => {