diff --git a/packages/app-bundle/README.md b/packages/app-bundle/README.md index 773b375a..118c9d86 100644 --- a/packages/app-bundle/README.md +++ b/packages/app-bundle/README.md @@ -4,6 +4,43 @@ The M2 artifact: the Amicode app surface, carried as an **overlay** on a pinned **canonical opencode** base — the mechanism that retires the fork at cutover while keeping every Amicode surface. +## Slice (b) — the complete app-graph delta — SHIPPED + +**Scope (corrected mid-slice)**: the complete fork-vs-base delta of the +app's build graph — `packages/{app,ui,session-ui,schema,core,sdk}` — 422 +files (per-package: app 94A/105M/1D, ui 142A/22M, session-ui 8A/16M, +schema 2A/5M, core 3A/23M, sdk 0A/2M). + +The planned additive-only scope **does not typecheck** — the finding that +drove the correction: ~10 additive app files depend on symbol-level +additions in modified files (`settings.developer`, `tabs.openPath`, +`model.pin`, `QuestionInfo.kind` — the typed question cards spanning +schema/core/sdk), and the fork's debug-bar deletion forces `layout.tsx` +into the overlay (base layout imports the deleted module). The fork delta +is a cross-cutting FEATURE delta, not an app-layer delta. + +**The M3 cutover port inventory is machine-derived** (manifest: +`server_coupled_port_inventory`, 35 files in schema/core/sdk): every app +feature whose types or runtime live in the fork's server-side packages. +At cutover the canonical server ships NONE of these — the bundle will hit +exactly these gaps — so each is a port-upstream / extension-service / +drop decision. Recorded, not resolved. + +**The true overlays** (big M-files upstream still evolves: home.tsx, +session-header, message-part, timeline, …) ride wholesale — correct for +the one-push cutover. The compose-vs-fork decomposition is deliberate +POST-cutover maintenance (`manifest.true_overlays` is the worklist). + +### Proofs (all green, 2026-08-21) + +1. **Equivalence** — every overlay file byte-identical to the fork's at the + pin (round-trip verified at extraction; manifest hash-verified at + materialization). Symlink-aware (`app/public/amico.svg` → ui asset). +2. **Composition** — `bun install` (4,695 pkgs) → `schema` typecheck → + `session-ui` typecheck + `ui` tsc build → `app` `tsgo -b` typecheck → + `app` **vite production build** (14.3s, 1,587 assets, Amicode surfaces + present in the emitted bundle) → **103/103 session-ui unit tests**. + ## Slice (a) — the complete `packages/ui` delta — SHIPPED **Scope**: every file under `packages/ui` the fork changed vs the upstream diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 1743e3e8..0e34ce76 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -1,20 +1,493 @@ { - "schema": 2, - "slice": "a", - "scope": "packages/ui (complete delta)", + "schema": 4, + "slices": "b (complete app-graph delta)", + "scope": "complete fork-vs-base delta of packages/{app,ui,session-ui,schema,core,sdk}", "fork_tag": "v1.18.10-amicode.14", "fork_sha": "1fe58157f78ce62515baaee69a9c1461eed42282", "upstream_base": "v1.18.12", "upstream_base_sha": "0dd6950d1b06958fbcdcadf0ad56258257ab7fdb", - "extracted_at": "2026-08-21T07:33:33.803Z", + "extracted_at": "2026-08-21T08:01:10.830Z", + "per_package": { + "packages/app": { + "A": 94, + "M": 105, + "D": 1 + }, + "packages/core": { + "A": 3, + "M": 23, + "D": 0 + }, + "packages/schema": { + "A": 2, + "M": 5, + "D": 0 + }, + "packages/sdk": { + "A": 0, + "M": 2, + "D": 0 + }, + "packages/session-ui": { + "A": 8, + "M": 16, + "D": 0 + }, + "packages/ui": { + "A": 142, + "M": 22, + "D": 0 + } + }, "counts": { - "added": 142, - "modified": 22, - "deleted": 0, - "unchanged": 1623 + "overlay_total": 422, + "deletions": 1, + "server_coupled": 35 }, - "deletions": [], + "server_coupled_port_inventory": [ + "packages/core/schema.json", + "packages/core/src/config.ts", + "packages/core/src/database/database.ts", + "packages/core/src/database/migration.gen.ts", + "packages/core/src/database/migration/20260813162312_shocking_karnak.ts", + "packages/core/src/database/schema.gen.ts", + "packages/core/src/flag/flag.ts", + "packages/core/src/models-dev.ts", + "packages/core/src/observability/otlp.ts", + "packages/core/src/permission.ts", + "packages/core/src/permission/provider-saved.ts", + "packages/core/src/permission/sql.ts", + "packages/core/src/provider-permission.ts", + "packages/core/src/session.ts", + "packages/core/src/session/runner/llm.ts", + "packages/core/src/tool/bash.ts", + "packages/core/src/tool/edit.ts", + "packages/core/src/tool/glob.ts", + "packages/core/src/tool/grep.ts", + "packages/core/src/tool/question.ts", + "packages/core/src/tool/read.ts", + "packages/core/src/tool/webfetch.ts", + "packages/core/src/tool/websearch.ts", + "packages/core/src/tool/write.ts", + "packages/core/src/v1/session.ts", + "packages/core/test/tool-question.test.ts", + "packages/schema/src/index.ts", + "packages/schema/src/prompt.ts", + "packages/schema/src/provider-permission.ts", + "packages/schema/src/question.ts", + "packages/schema/src/v1/question.ts", + "packages/schema/src/v1/session.ts", + "packages/schema/test/question.test.ts", + "packages/sdk/js/src/v2/gen/sdk.gen.ts", + "packages/sdk/js/src/v2/gen/types.gen.ts" + ], + "true_overlays": [ + "packages/app/e2e/regression/project-picker-recent-search.spec.ts", + "packages/app/e2e/regression/review-line-comment.spec.ts", + "packages/app/e2e/regression/session-timeline-tool-projection.spec.ts", + "packages/app/e2e/regression/tab-navigate-mousedown.spec.ts", + "packages/app/e2e/smoke/session-timeline.spec.ts", + "packages/app/e2e/utils/waits.ts", + "packages/app/index.html", + "packages/app/playwright.config.ts", + "packages/app/public/oc-theme-preload.js", + "packages/app/src/app.tsx", + "packages/app/src/components/prompt-input-v2.tsx", + "packages/app/src/components/prompt-input.tsx", + "packages/app/src/components/prompt-input/attachments.ts", + "packages/app/src/components/prompt-input/placeholder.ts", + "packages/app/src/components/prompt-project-selector.tsx", + "packages/app/src/components/session-context-usage.tsx", + "packages/app/src/components/session/index.ts", + "packages/app/src/components/session/session-context-tab.tsx", + "packages/app/src/components/session/session-header.tsx", + "packages/app/src/components/session/session-new-view.tsx", + "packages/app/src/components/settings-dialog.tsx", + "packages/app/src/components/settings-v2/dialog-settings-v2.tsx", + "packages/app/src/components/settings-v2/general.tsx", + "packages/app/src/components/settings-v2/settings-v2.css", + "packages/app/src/components/status-popover-body.tsx", + "packages/app/src/components/status-popover.tsx", + "packages/app/src/components/terminal.tsx", + "packages/app/src/components/titlebar-tab-nav.css", + "packages/app/src/components/titlebar-tab-strip.tsx", + "packages/app/src/components/titlebar.tsx", + "packages/app/src/context/command.tsx", + "packages/app/src/context/directory-sync.ts", + "packages/app/src/context/global-sync/bootstrap.test.ts", + "packages/app/src/context/global-sync/child-store.ts", + "packages/app/src/context/global-sync/event-reducer.test.ts", + "packages/app/src/context/global-sync/event-reducer.ts", + "packages/app/src/context/global-sync/session-cache.test.ts", + "packages/app/src/context/global-sync/session-cache.ts", + "packages/app/src/context/global-sync/types.ts", + "packages/app/src/context/language.tsx", + "packages/app/src/context/layout-tabs.ts", + "packages/app/src/context/layout.tsx", + "packages/app/src/context/local-agent.test.ts", + "packages/app/src/context/local-agent.ts", + "packages/app/src/context/local.tsx", + "packages/app/src/context/models.tsx", + "packages/app/src/context/platform.tsx", + "packages/app/src/context/server-sdk.test.ts", + "packages/app/src/context/server-sdk.tsx", + "packages/app/src/context/server-session.ts", + "packages/app/src/context/server.tsx", + "packages/app/src/context/settings.tsx", + "packages/app/src/context/tabs.tsx", + "packages/app/src/desktop-menu.ts", + "packages/app/src/entry.tsx", + "packages/app/src/i18n/ar.ts", + "packages/app/src/i18n/br.ts", + "packages/app/src/i18n/bs.ts", + "packages/app/src/i18n/da.ts", + "packages/app/src/i18n/de.ts", + "packages/app/src/i18n/en.ts", + "packages/app/src/i18n/es.ts", + "packages/app/src/i18n/fr.ts", + "packages/app/src/i18n/ja.ts", + "packages/app/src/i18n/ko.ts", + "packages/app/src/i18n/no.ts", + "packages/app/src/i18n/pl.ts", + "packages/app/src/i18n/ru.ts", + "packages/app/src/i18n/th.ts", + "packages/app/src/i18n/tr.ts", + "packages/app/src/i18n/uk.ts", + "packages/app/src/i18n/zh.ts", + "packages/app/src/i18n/zht.ts", + "packages/app/src/index.css", + "packages/app/src/pages/error.tsx", + "packages/app/src/pages/home-session-archive.test.ts", + "packages/app/src/pages/home-session-archive.ts", + "packages/app/src/pages/home.tsx", + "packages/app/src/pages/home/home-sessions-view.tsx", + "packages/app/src/pages/layout-new.tsx", + "packages/app/src/pages/layout.tsx", + "packages/app/src/pages/layout/session-tab-avatar.tsx", + "packages/app/src/pages/layout/sidebar-items.tsx", + "packages/app/src/pages/layout/sidebar-workspace.tsx", + "packages/app/src/pages/new-session.tsx", + "packages/app/src/pages/new-session/new-session-draft-controller.ts", + "packages/app/src/pages/new-session/new-session-view.tsx", + "packages/app/src/pages/session.tsx", + "packages/app/src/pages/session/composer/session-composer-controls.ts", + "packages/app/src/pages/session/composer/session-composer-region-controller.ts", + "packages/app/src/pages/session/composer/session-composer-region.tsx", + "packages/app/src/pages/session/composer/session-composer-state.ts", + "packages/app/src/pages/session/composer/session-question-dock.tsx", + "packages/app/src/pages/session/composer/session-revert-dock.tsx", + "packages/app/src/pages/session/helpers.test.ts", + "packages/app/src/pages/session/helpers.ts", + "packages/app/src/pages/session/session-panel-width.test.ts", + "packages/app/src/pages/session/session-panel-width.ts", + "packages/app/src/pages/session/session-side-panel.tsx", + "packages/app/src/pages/session/timeline/message-timeline.tsx", + "packages/app/src/pages/session/use-session-commands.tsx", + "packages/app/src/pages/session/v2/review-panel-v2-state.ts", + "packages/app/src/utils/server-compat.ts", + "packages/app/src/wsl/settings-model.test.ts", + "packages/app/src/wsl/settings-model.ts", + "packages/session-ui/package.json", + "packages/session-ui/src/components/markdown-stream.test.ts", + "packages/session-ui/src/components/markdown-stream.ts", + "packages/session-ui/src/components/markdown.css", + "packages/session-ui/src/components/markdown.tsx", + "packages/session-ui/src/components/message-part.css", + "packages/session-ui/src/components/message-part.tsx", + "packages/session-ui/src/components/session-retry.tsx", + "packages/session-ui/src/components/session-turn.css", + "packages/session-ui/src/components/tool-error-card.tsx", + "packages/session-ui/src/components/tool-status-title.tsx", + "packages/session-ui/src/v2/components/prompt-input/attachments.ts", + "packages/session-ui/src/v2/components/prompt-input/index.tsx", + "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx", + "packages/session-ui/src/v2/components/session-review-v2.css", + "packages/session-ui/src/v2/components/session-review-v2.tsx", + "packages/ui/package.json", + "packages/ui/src/assets/favicon/site.webmanifest", + "packages/ui/src/components/dialog.css", + "packages/ui/src/components/dock-surface.css", + "packages/ui/src/components/favicon.tsx", + "packages/ui/src/components/icon.tsx", + "packages/ui/src/components/logo.css", + "packages/ui/src/components/logo.tsx", + "packages/ui/src/components/text-field.tsx", + "packages/ui/src/context/dialog.tsx", + "packages/ui/src/context/marked.tsx", + "packages/ui/src/i18n/en.ts", + "packages/ui/src/styles/index.css", + "packages/ui/src/styles/theme.css", + "packages/ui/src/theme/context.tsx", + "packages/ui/src/theme/themes/oc-2.json", + "packages/ui/src/v2/components/dialog-v2.css", + "packages/ui/src/v2/components/icon-button-v2.tsx", + "packages/ui/src/v2/components/icon.tsx", + "packages/ui/src/v2/components/switch-v2.css", + "packages/ui/src/v2/components/tooltip-v2.css", + "packages/ui/src/v2/components/wordmark-v2.tsx" + ], + "deletions": [ + "packages/app/src/components/debug-bar.tsx" + ], "classification": { + "packages/app/CONTEXT.md": "A", + "packages/app/docs/adr/0001-chat-first-landing.md": "A", + "packages/app/docs/adr/0002-brain-as-chat-background.md": "A", + "packages/app/docs/adr/0003-context-tree-top-panel.md": "A", + "packages/app/docs/adr/0004-context-tree-to-side-panel.md": "A", + "packages/app/docs/design/agentic-surfaces.md": "A", + "packages/app/e2e/regression/chrome-surfaces.spec.ts": "A", + "packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts": "A", + "packages/app/e2e/regression/pane-boot-watch.spec.ts": "A", + "packages/app/e2e/regression/project-picker-recent-search.spec.ts": "M", + "packages/app/e2e/regression/review-line-comment.spec.ts": "M", + "packages/app/e2e/regression/session-timeline-tool-projection.spec.ts": "M", + "packages/app/e2e/regression/tab-navigate-mousedown.spec.ts": "M", + "packages/app/e2e/regression/workbench-drops.spec.ts": "A", + "packages/app/e2e/regression/workbench-panes.spec.ts": "A", + "packages/app/e2e/smoke/session-timeline.spec.ts": "M", + "packages/app/e2e/utils/waits.ts": "M", + "packages/app/index.html": "M", + "packages/app/playwright.config.ts": "M", + "packages/app/public/amico.svg": "A", + "packages/app/public/assets/Aero-Regular.ttf": "A", + "packages/app/public/assets/JuliaMono-Regular.woff2": "A", + "packages/app/public/assets/RacingSansOne-Regular.woff2": "A", + "packages/app/public/oc-theme-preload.js": "M", + "packages/app/src/amicode/inspector/device-inspector.tsx": "A", + "packages/app/src/amicode/inspector/inspector-bridge.ts": "A", + "packages/app/src/amicode/inspector/inspector-context.tsx": "A", + "packages/app/src/amicode/inspector/run-inspector.tsx": "A", + "packages/app/src/app.tsx": "M", + "packages/app/src/components/amicode-default-model-value.ts": "A", + "packages/app/src/components/amicode-default-model.test.ts": "A", + "packages/app/src/components/amicode-default-model.tsx": "A", + "packages/app/src/components/amicode-defaults-capsule.tsx": "A", + "packages/app/src/components/amicode-file-ref-bridge.tsx": "A", + "packages/app/src/components/connection-banner.tsx": "A", + "packages/app/src/components/dialog-new-project.tsx": "A", + "packages/app/src/components/pane-bridge.tsx": "A", + "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "A", + "packages/app/src/components/prompt-input-v2.tsx": "M", + "packages/app/src/components/prompt-input.tsx": "M", + "packages/app/src/components/prompt-input/attachments.ts": "M", + "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "A", + "packages/app/src/components/prompt-input/clipboard-bridge.ts": "A", + "packages/app/src/components/prompt-input/placeholder.ts": "M", + "packages/app/src/components/prompt-project-selector.tsx": "M", + "packages/app/src/components/report-bug-button.css": "A", + "packages/app/src/components/report-bug-button.stories.tsx": "A", + "packages/app/src/components/report-bug-button.tsx": "A", + "packages/app/src/components/session-context-usage.tsx": "M", + "packages/app/src/components/session/context-warning-banner.tsx": "A", + "packages/app/src/components/session/index.ts": "M", + "packages/app/src/components/session/panel-menu.tsx": "A", + "packages/app/src/components/session/session-chats-dropdown.test.ts": "A", + "packages/app/src/components/session/session-context-tab.tsx": "M", + "packages/app/src/components/session/session-header.tsx": "M", + "packages/app/src/components/session/session-new-view.tsx": "M", + "packages/app/src/components/session/session-preview-tab.tsx": "A", + "packages/app/src/components/session/use-context-warning.ts": "A", + "packages/app/src/components/settings-dialog.tsx": "M", + "packages/app/src/components/settings-v2/data-storage-controller.ts": "A", + "packages/app/src/components/settings-v2/data-storage.tsx": "A", + "packages/app/src/components/settings-v2/developer-tools-controller.ts": "A", + "packages/app/src/components/settings-v2/developer-tools.tsx": "A", + "packages/app/src/components/settings-v2/dialog-settings-v2.tsx": "M", + "packages/app/src/components/settings-v2/general.tsx": "M", + "packages/app/src/components/settings-v2/permissions.tsx": "A", + "packages/app/src/components/settings-v2/settings-v2.css": "M", + "packages/app/src/components/split-frame.tsx": "A", + "packages/app/src/components/status-popover-body.tsx": "M", + "packages/app/src/components/status-popover-model.test.ts": "A", + "packages/app/src/components/status-popover-model.ts": "A", + "packages/app/src/components/status-popover.tsx": "M", + "packages/app/src/components/terminal.tsx": "M", + "packages/app/src/components/titlebar-channel.test.ts": "A", + "packages/app/src/components/titlebar-channel.ts": "A", + "packages/app/src/components/titlebar-tab-nav.css": "M", + "packages/app/src/components/titlebar-tab-strip.tsx": "M", + "packages/app/src/components/titlebar.tsx": "M", + "packages/app/src/components/vault-browser-model.test.ts": "A", + "packages/app/src/components/vault-browser-model.ts": "A", + "packages/app/src/components/vault-browser.tsx": "A", + "packages/app/src/components/vault-panel.tsx": "A", + "packages/app/src/components/workbench-panel.tsx": "A", + "packages/app/src/context/command.tsx": "M", + "packages/app/src/context/directory-sync.ts": "M", + "packages/app/src/context/global-sync/bootstrap.test.ts": "M", + "packages/app/src/context/global-sync/child-store.ts": "M", + "packages/app/src/context/global-sync/event-reducer.test.ts": "M", + "packages/app/src/context/global-sync/event-reducer.ts": "M", + "packages/app/src/context/global-sync/session-cache.test.ts": "M", + "packages/app/src/context/global-sync/session-cache.ts": "M", + "packages/app/src/context/global-sync/types.ts": "M", + "packages/app/src/context/language.tsx": "M", + "packages/app/src/context/layout-tabs.ts": "M", + "packages/app/src/context/layout.tsx": "M", + "packages/app/src/context/local-agent.test.ts": "M", + "packages/app/src/context/local-agent.ts": "M", + "packages/app/src/context/local.tsx": "M", + "packages/app/src/context/models.tsx": "M", + "packages/app/src/context/platform.tsx": "M", + "packages/app/src/context/server-sdk.test.ts": "M", + "packages/app/src/context/server-sdk.tsx": "M", + "packages/app/src/context/server-session.ts": "M", + "packages/app/src/context/server.tsx": "M", + "packages/app/src/context/settings.tsx": "M", + "packages/app/src/context/split.tsx": "A", + "packages/app/src/context/tabs.tsx": "M", + "packages/app/src/context/vault-panel.ts": "A", + "packages/app/src/context/workbench.tsx": "A", + "packages/app/src/context/zoom-keybind.test.ts": "A", + "packages/app/src/design-polish.css": "A", + "packages/app/src/desktop-menu.ts": "M", + "packages/app/src/entry.tsx": "M", + "packages/app/src/i18n/ar.ts": "M", + "packages/app/src/i18n/br.ts": "M", + "packages/app/src/i18n/bs.ts": "M", + "packages/app/src/i18n/da.ts": "M", + "packages/app/src/i18n/de.ts": "M", + "packages/app/src/i18n/en.ts": "M", + "packages/app/src/i18n/es.ts": "M", + "packages/app/src/i18n/fr.ts": "M", + "packages/app/src/i18n/ja.ts": "M", + "packages/app/src/i18n/ko.ts": "M", + "packages/app/src/i18n/no.ts": "M", + "packages/app/src/i18n/pl.ts": "M", + "packages/app/src/i18n/ru.ts": "M", + "packages/app/src/i18n/th.ts": "M", + "packages/app/src/i18n/tr.ts": "M", + "packages/app/src/i18n/uk.ts": "M", + "packages/app/src/i18n/zh.ts": "M", + "packages/app/src/i18n/zht.ts": "M", + "packages/app/src/index.css": "M", + "packages/app/src/pages/error.tsx": "M", + "packages/app/src/pages/home-projects.test.ts": "A", + "packages/app/src/pages/home-projects.ts": "A", + "packages/app/src/pages/home-session-archive.test.ts": "M", + "packages/app/src/pages/home-session-archive.ts": "M", + "packages/app/src/pages/home.tsx": "M", + "packages/app/src/pages/home/home-sessions-view.tsx": "M", + "packages/app/src/pages/layout-new.tsx": "M", + "packages/app/src/pages/layout.tsx": "M", + "packages/app/src/pages/layout/session-tab-avatar.tsx": "M", + "packages/app/src/pages/layout/sidebar-items.tsx": "M", + "packages/app/src/pages/layout/sidebar-workspace.tsx": "M", + "packages/app/src/pages/new-session.tsx": "M", + "packages/app/src/pages/new-session/new-session-draft-controller.ts": "M", + "packages/app/src/pages/new-session/new-session-view.tsx": "M", + "packages/app/src/pages/session.tsx": "M", + "packages/app/src/pages/session/composer/bug-dock-controller.test.ts": "A", + "packages/app/src/pages/session/composer/bug-dock-controller.ts": "A", + "packages/app/src/pages/session/composer/bug-dock.ts": "A", + "packages/app/src/pages/session/composer/report-bug.test.ts": "A", + "packages/app/src/pages/session/composer/report-bug.ts": "A", + "packages/app/src/pages/session/composer/session-bug-dock.stories.tsx": "A", + "packages/app/src/pages/session/composer/session-bug-dock.tsx": "A", + "packages/app/src/pages/session/composer/session-composer-controls.ts": "M", + "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "M", + "packages/app/src/pages/session/composer/session-composer-region.tsx": "M", + "packages/app/src/pages/session/composer/session-composer-state.ts": "M", + "packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts": "A", + "packages/app/src/pages/session/composer/session-question-dock.helpers.ts": "A", + "packages/app/src/pages/session/composer/session-question-dock.tsx": "M", + "packages/app/src/pages/session/composer/session-revert-dock.tsx": "M", + "packages/app/src/pages/session/helpers.test.ts": "M", + "packages/app/src/pages/session/helpers.ts": "M", + "packages/app/src/pages/session/session-panel-width.test.ts": "M", + "packages/app/src/pages/session/session-panel-width.ts": "M", + "packages/app/src/pages/session/session-side-panel-structure.test.ts": "A", + "packages/app/src/pages/session/session-side-panel.tsx": "M", + "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "A", + "packages/app/src/pages/session/timeline/message-timeline.tsx": "M", + "packages/app/src/pages/session/use-amicode-commands.test.ts": "A", + "packages/app/src/pages/session/use-amicode-commands.tsx": "A", + "packages/app/src/pages/session/use-session-commands.tsx": "M", + "packages/app/src/pages/session/v2/accumulate-diffs.test.ts": "A", + "packages/app/src/pages/session/v2/accumulate-diffs.ts": "A", + "packages/app/src/pages/session/v2/review-panel-v2-state.test.ts": "A", + "packages/app/src/pages/session/v2/review-panel-v2-state.ts": "M", + "packages/app/src/utils/amicode-bridge.ts": "A", + "packages/app/src/utils/amicode-bug-report.test.ts": "A", + "packages/app/src/utils/amicode-bug-report.ts": "A", + "packages/app/src/utils/amicode-fetch.ts": "A", + "packages/app/src/utils/amicode-hidden-project.test.ts": "A", + "packages/app/src/utils/amicode-hidden-project.ts": "A", + "packages/app/src/utils/amicode-pane.ts": "A", + "packages/app/src/utils/amicode-route-info.ts": "A", + "packages/app/src/utils/chrome-dropdown.ts": "A", + "packages/app/src/utils/global-clipboard.test.ts": "A", + "packages/app/src/utils/global-clipboard.ts": "A", + "packages/app/src/utils/pane-bridge.ts": "A", + "packages/app/src/utils/serialize-session.test.ts": "A", + "packages/app/src/utils/serialize-session.ts": "A", + "packages/app/src/utils/server-compat.ts": "M", + "packages/app/src/utils/start-prompt.ts": "A", + "packages/app/src/utils/web-zoom.test.ts": "A", + "packages/app/src/utils/web-zoom.ts": "A", + "packages/app/src/utils/webview-context-menu.ts": "A", + "packages/app/src/wsl/settings-model.test.ts": "M", + "packages/app/src/wsl/settings-model.ts": "M", + "packages/core/schema.json": "M", + "packages/core/src/config.ts": "M", + "packages/core/src/database/database.ts": "M", + "packages/core/src/database/migration.gen.ts": "M", + "packages/core/src/database/migration/20260813162312_shocking_karnak.ts": "A", + "packages/core/src/database/schema.gen.ts": "M", + "packages/core/src/flag/flag.ts": "M", + "packages/core/src/models-dev.ts": "M", + "packages/core/src/observability/otlp.ts": "M", + "packages/core/src/permission.ts": "M", + "packages/core/src/permission/provider-saved.ts": "A", + "packages/core/src/permission/sql.ts": "M", + "packages/core/src/provider-permission.ts": "A", + "packages/core/src/session.ts": "M", + "packages/core/src/session/runner/llm.ts": "M", + "packages/core/src/tool/bash.ts": "M", + "packages/core/src/tool/edit.ts": "M", + "packages/core/src/tool/glob.ts": "M", + "packages/core/src/tool/grep.ts": "M", + "packages/core/src/tool/question.ts": "M", + "packages/core/src/tool/read.ts": "M", + "packages/core/src/tool/webfetch.ts": "M", + "packages/core/src/tool/websearch.ts": "M", + "packages/core/src/tool/write.ts": "M", + "packages/core/src/v1/session.ts": "M", + "packages/core/test/tool-question.test.ts": "M", + "packages/schema/src/index.ts": "M", + "packages/schema/src/prompt.ts": "M", + "packages/schema/src/provider-permission.ts": "A", + "packages/schema/src/question.ts": "M", + "packages/schema/src/v1/question.ts": "M", + "packages/schema/src/v1/session.ts": "M", + "packages/schema/test/question.test.ts": "A", + "packages/sdk/js/src/v2/gen/sdk.gen.ts": "M", + "packages/sdk/js/src/v2/gen/types.gen.ts": "M", + "packages/session-ui/package.json": "M", + "packages/session-ui/src/components/group-parts.test.ts": "A", + "packages/session-ui/src/components/markdown-file-refs.test.ts": "A", + "packages/session-ui/src/components/markdown-file-refs.ts": "A", + "packages/session-ui/src/components/markdown-stream.test.ts": "M", + "packages/session-ui/src/components/markdown-stream.ts": "M", + "packages/session-ui/src/components/markdown.css": "M", + "packages/session-ui/src/components/markdown.tsx": "M", + "packages/session-ui/src/components/message-part-css.test.ts": "A", + "packages/session-ui/src/components/message-part-groups.ts": "A", + "packages/session-ui/src/components/message-part-skill.test.ts": "A", + "packages/session-ui/src/components/message-part-skill.ts": "A", + "packages/session-ui/src/components/message-part.css": "M", + "packages/session-ui/src/components/message-part.tsx": "M", + "packages/session-ui/src/components/session-retry.tsx": "M", + "packages/session-ui/src/components/session-turn.css": "M", + "packages/session-ui/src/components/tool-error-card.tsx": "M", + "packages/session-ui/src/components/tool-status-title.tsx": "M", + "packages/session-ui/src/util/clipboard.ts": "A", + "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "M", + "packages/session-ui/src/v2/components/prompt-input/index.tsx": "M", + "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "M", + "packages/session-ui/src/v2/components/session-review-v2.css": "M", + "packages/session-ui/src/v2/components/session-review-v2.tsx": "M", "packages/ui/package.json": "M", "packages/ui/src/amicode/amico-presence.css": "A", "packages/ui/src/amicode/amico-presence.stories.tsx": "A", @@ -181,6 +654,264 @@ "packages/ui/src/v2/components/wordmark-v2.tsx": "M" }, "files": { + "packages/app/CONTEXT.md": "87b15a82a89ea1c82e0d647c169a259a1ef5193893854f2cfed54526762898ca", + "packages/app/docs/adr/0001-chat-first-landing.md": "d19c42a8aa56f585f92a3e0ffd072c55804ca029c6d19fb7d772c69d7e5de3be", + "packages/app/docs/adr/0002-brain-as-chat-background.md": "bda9bc065e16684e1daffdc3f7d8ac5b3c81a82de5c27fa1ba7d6268659c32c1", + "packages/app/docs/adr/0003-context-tree-top-panel.md": "693f19dd5c155b881dd35b85412ad62fef0683a6934d7cf1c59c9a679d1a8eeb", + "packages/app/docs/adr/0004-context-tree-to-side-panel.md": "271f31a677547be2e11c0c19364ae97b04a2ea353a9ab22c87bde1afab64babc", + "packages/app/docs/design/agentic-surfaces.md": "94a117b2790ff0157ee01684fd49dcb32d241b3572e9df77f81f721967dc52bf", + "packages/app/e2e/regression/chrome-surfaces.spec.ts": "c7ff1b82bf84e577fee0e765b0c357c040d4d7cdefdd537702d7dbd186bfedf6", + "packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts": "b0debddea5051defd859b9c8db2439801592d984fa89ddaf880494722ef2cb21", + "packages/app/e2e/regression/pane-boot-watch.spec.ts": "3aa869f8b1d9881797e14dceb9854c4408f768f490d4db8722e29e511bca1833", + "packages/app/e2e/regression/project-picker-recent-search.spec.ts": "64f4249f29cfce29d5e755a544c5aa2f27599d9fd874d363628f696f5f0e02b9", + "packages/app/e2e/regression/review-line-comment.spec.ts": "953e9c67ed4cee91968369088237cf2288558eaa55e55b61f385571bccad7003", + "packages/app/e2e/regression/session-timeline-tool-projection.spec.ts": "c0bb866b113d38ddec084e04027030cf5b7c81f1fe4e6ee88fcd7b1b161296c3", + "packages/app/e2e/regression/tab-navigate-mousedown.spec.ts": "8521d43183f0df624449edbba8ee4dc646f0ecb84a9c5a04d275613f9da875ae", + "packages/app/e2e/regression/workbench-drops.spec.ts": "0188f4f1ff07a40945a4900bfa85e21a4f578be9f18257a803200a18ce41ac05", + "packages/app/e2e/regression/workbench-panes.spec.ts": "e0dfb84bd1b8be00a89cf48387aeaab5ec75845e8efbc8e4fa8252204fb27e8f", + "packages/app/e2e/smoke/session-timeline.spec.ts": "7dc5b08126af46fa11727bd0ff2f3b25a04e430afbf84c8bafb4b73f4609dd2d", + "packages/app/e2e/utils/waits.ts": "53959eeae1e6562a2e6c578b261140c74fe9fc30e1e4c46295d3252b6d274a74", + "packages/app/index.html": "9240501ab6c74b84599a5eb9d852c4e606dc60eb2e69a4ac84b182bf8b1b5b68", + "packages/app/playwright.config.ts": "b7dae0b39248b4ddf7cba829af96ac7fe3cc919add9711ecb5292d9bd52e1805", + "packages/app/public/amico.svg": "a14b9d543d895bcdf0758f7b9ef5908ee0acaac794446494af059b159247db8f", + "packages/app/public/assets/Aero-Regular.ttf": "0ebf14f80d05c3a82c766124ab31196c310014744333d397425ac82a1e3537c4", + "packages/app/public/assets/JuliaMono-Regular.woff2": "43f5dfca02a03035f89799da5af150f28d5c4aff729753076094c687fc61b063", + "packages/app/public/assets/RacingSansOne-Regular.woff2": "2ff92c8abe7a35962922c54da215d68690f8502463698b1b595bed597b63e3ff", + "packages/app/public/oc-theme-preload.js": "483f1c347e8f4c7deeac3c20d324dc1329631a682da9c44e56b7b56b8df9f69b", + "packages/app/src/amicode/inspector/device-inspector.tsx": "5ab63709e88176bb4dad1072ffe28fd59ad67d414c9d844ee6c55353df50df79", + "packages/app/src/amicode/inspector/inspector-bridge.ts": "460dbf4509b4e56a8eacd6cac7adcb3a63c6cd3e417d851df5f3e3b259095d3e", + "packages/app/src/amicode/inspector/inspector-context.tsx": "ac81b9e0e2a4035f85b6af68b7f0cefcb9f256a175b244ba13136f3a307c0c77", + "packages/app/src/amicode/inspector/run-inspector.tsx": "63a6351fad12a8377550eb87f7602a712bae586fe482ff46cd847c8544fc6e8a", + "packages/app/src/app.tsx": "48912ae64588cd21c71a2e6bbd563f174c19861defb18aa0b323bfd22e975342", + "packages/app/src/components/amicode-default-model-value.ts": "416bb4a7cf051c678bdfc5246ddc5796a9a6efe48d1e5c2e933756800cfdae8d", + "packages/app/src/components/amicode-default-model.test.ts": "941750d074706bd5f4e433c9737b19ebc0ac0f9ba9cd1faaa599a24b22126e05", + "packages/app/src/components/amicode-default-model.tsx": "16a2de1feb12e07416fe5fbb25879391f912ee0909b553c868c1b531f2ed1f2d", + "packages/app/src/components/amicode-defaults-capsule.tsx": "6c0b143695f63423e651e0123ece7d5dc15ef2cb3e6ab9aaac566287e3efe6da", + "packages/app/src/components/amicode-file-ref-bridge.tsx": "11c17d7fdb29d16530e188b13ee8af728f37d9adfbb518084106ce50cc0c527a", + "packages/app/src/components/connection-banner.tsx": "69aae9294b797d06b21cc3958c7bfa7a6dad451f14399638037ee19e4c9a0203", + "packages/app/src/components/dialog-new-project.tsx": "102fc33e8eeaf3c76ddd1b953b7b97995d30c02a3579f8b7036e47448a1582a8", + "packages/app/src/components/pane-bridge.tsx": "756b3306b51c963fac6778716f6c6c4626f0f3bf75c93b7cb4cab58ea0bf3d4e", + "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "8c15844499378d6ae1d84d7c6f5b99bf37cd9baf5b35a45dc1535246f3d324c6", + "packages/app/src/components/prompt-input-v2.tsx": "9aabf52c91628015d4eeff15292696540c97a97572b5dadd26ee328631c0647a", + "packages/app/src/components/prompt-input.tsx": "641c5db9bd27a90887935c9b6fae45ee250504f17b25891eb51f292e4273d320", + "packages/app/src/components/prompt-input/attachments.ts": "c3c28df0d2f18378d39e17147a4c64e8ea01d63b97c39e1874ed9445ca671bb1", + "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "cbc50495466c169d4f558cdb0aea5fc9215baf5257ddbde0414491826e4087d9", + "packages/app/src/components/prompt-input/clipboard-bridge.ts": "556f79e96388bee80fa1c38b5e916f7efd1d87227c1e9eded8ddc59de47b961c", + "packages/app/src/components/prompt-input/placeholder.ts": "f085dc0cae6c2267f1a4a0e819dc9061d97ebdabb3baeae44f91376fbf1a4837", + "packages/app/src/components/prompt-project-selector.tsx": "1d0970b4d067261d3b88af089537be8200369e490d2e02553043a140d9a8e961", + "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", + "packages/app/src/components/report-bug-button.stories.tsx": "e3d28ffec2f9682227a3f6ded24674afbbf0416d64369cac4711bb12d7709e1a", + "packages/app/src/components/report-bug-button.tsx": "a1beb8c94b4faaa4c7db3ab68de349f8a8a03cb3a058ce447f552aed35848d43", + "packages/app/src/components/session-context-usage.tsx": "f5b07800bbe571067a8625d6175a29ec1626c365fc336e96db92f6506a84aaa3", + "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", + "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", + "packages/app/src/components/session/panel-menu.tsx": "d07e11d635615107c9acdb06460460b5015b4141fb3f9c4002837933f960e22f", + "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", + "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", + "packages/app/src/components/session/session-header.tsx": "e40067c61f958395892dea880d9424ec5c17fa6bcc093e9b33baaed922e2738e", + "packages/app/src/components/session/session-new-view.tsx": "3043eab823572207da9de02028bf7698600e074bef43b47f5e29b325961048c6", + "packages/app/src/components/session/session-preview-tab.tsx": "b6b4b8fe6f751499e0d3005e1f12224351df4f28f221bba10fc2f91c0f09e777", + "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", + "packages/app/src/components/settings-dialog.tsx": "da3bc01dad4ece4214c14083ae4e7dea87ef64ef9829b315b867fdafccdb6916", + "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", + "packages/app/src/components/settings-v2/data-storage.tsx": "a56b423b32c952c4379688d88d13a9f43b163a4169b9393f8c35b18a763a1180", + "packages/app/src/components/settings-v2/developer-tools-controller.ts": "bfe2d97b034fb97802123c5d03de8a1feff495ae09557b5619e2f1df1d9a68f6", + "packages/app/src/components/settings-v2/developer-tools.tsx": "96ccc0e2ba8ee40eeeaea4933ad176a4029a1b972364e070f0cb027d8fb0a1db", + "packages/app/src/components/settings-v2/dialog-settings-v2.tsx": "253c32f1309aa5a474075f8fe5d3ad2f40946b772903e07d38a6560f9c4e634c", + "packages/app/src/components/settings-v2/general.tsx": "e20b03b6f6bb6fc6418bc60ce6dc5af2a02fedc1da76b4ab6a861a2267b909ef", + "packages/app/src/components/settings-v2/permissions.tsx": "89b491b4971f23c5308f34096c2645b01831ceb1ed9e604fd494af9317d48f0c", + "packages/app/src/components/settings-v2/settings-v2.css": "481af8fec5288cdee579a66c9718a99c514b1cf732ab5bbb2829766d55af29ef", + "packages/app/src/components/split-frame.tsx": "44c53737f72027b461097c03df48a4f411d04a82d02b1fce4901813c73dfcc72", + "packages/app/src/components/status-popover-body.tsx": "5e2fc75b6974960dd86f885d4496d8e53d0ff2d2226221e4d48309956d612fd8", + "packages/app/src/components/status-popover-model.test.ts": "10634035bab0f31de24564e0d6c8413a0f7434fb2c08ac3ef722981a69dd08e7", + "packages/app/src/components/status-popover-model.ts": "ec5a5a3caf9344fe617c7bc668af81d96ad4290378c34186cd4cd959821f1c85", + "packages/app/src/components/status-popover.tsx": "47d308ada46d3c0d4c43ead93e97016a04ba5b17caaf9e5c6d3a92d1b1b6d019", + "packages/app/src/components/terminal.tsx": "3d5468362c63bd01661c745612e3d64a6d9e8a999d66b38202c5bb78401dcea6", + "packages/app/src/components/titlebar-channel.test.ts": "fe4dbe37e8b599878e16f2e56a62814fa787a94f2e523b3ca3f2d5310cf940cb", + "packages/app/src/components/titlebar-channel.ts": "2e01bb619f36799a684952538bc47844bac96a2c5eaaed240c6317d2b07b8efa", + "packages/app/src/components/titlebar-tab-nav.css": "ab5dd7bc4810dd5c4c5f6550b10e98985823ca8e0b5365b1c5ddde6ff0b057a5", + "packages/app/src/components/titlebar-tab-strip.tsx": "182a21a367b36dca30a570ea98015f434cec49db671edca6e921778a3abeb6e8", + "packages/app/src/components/titlebar.tsx": "ca2e24f3d73f92b075ec120acc399c23e1b04feed6f05878817d78a692937a0f", + "packages/app/src/components/vault-browser-model.test.ts": "7ca11eae3574033d58081bc4126b1ec772a5b496573c70159f05497a1365f4cd", + "packages/app/src/components/vault-browser-model.ts": "d9ef6ff588a96ae79b13c3ae0c2a442a42b59021dd501b3a17bd810dda6f7b20", + "packages/app/src/components/vault-browser.tsx": "509efa874d69bcd07ef2495a025bc8de2b2fa582736003ff709b4ca76096274b", + "packages/app/src/components/vault-panel.tsx": "1d9f06f42b94ffb94a28bfd6d2693a36c75ceb969bcd434081b4cd2fbf99665c", + "packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690", + "packages/app/src/context/command.tsx": "06ffdc83f39186481bba8120e4bc1b14c2160d1dc789a29e0a07ffc7c5a2cd35", + "packages/app/src/context/directory-sync.ts": "0d9e6aa3cff6c6654c98928f59c4e2fcced04f42b167184eea77be04c00371ee", + "packages/app/src/context/global-sync/bootstrap.test.ts": "149c0241b378b6ce837aef45e1ba257dbfed88de37c70affa13f513e9c72338d", + "packages/app/src/context/global-sync/child-store.ts": "b13a513c93b8fec7b656593d842e9cfa30f346a0911dc0f516783ccc5fa4c07d", + "packages/app/src/context/global-sync/event-reducer.test.ts": "600cd7654854b910f25c61b20d8ae69f043f52ed544e6bf9be5ee9e5bbd6c96e", + "packages/app/src/context/global-sync/event-reducer.ts": "667fbae1259f6deed77573edd2b742beee4f825b4306b6d861670443e381a55f", + "packages/app/src/context/global-sync/session-cache.test.ts": "c66d790c6336794b2b1d707e2739fe0992ccad7ed3b96187b124b5c783ef332a", + "packages/app/src/context/global-sync/session-cache.ts": "1849408caba0f114809f37153e7d531bf2776572433880a96c5ebd31d7b30aa3", + "packages/app/src/context/global-sync/types.ts": "901c9bd1fda63dab676d33dc1a8582a29f8ee00aa20458e843aed5f93414fa84", + "packages/app/src/context/language.tsx": "cb545e04c128bf981b2b72ac407f9220cf9e80337fe8c87482ffe85407e0cf9a", + "packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5", + "packages/app/src/context/layout.tsx": "54dbe82c704c92109eb392b512fe2d828a5ff00a14315173ad8923bf0621d788", + "packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade", + "packages/app/src/context/local-agent.ts": "0aab67e695dc3bb45a733ac0df80a0a5e14cfe29b2375b6dc3cd07fbccea33e2", + "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", + "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", + "packages/app/src/context/platform.tsx": "d4ee3f33e485f5fd6d6d449edcc7da0bbbed733e139f9f293fd011e78153c6c5", + "packages/app/src/context/server-sdk.test.ts": "e9f0390dec05e8c55f4fc3127e18c05c2674856d853bde30a67952a313098d52", + "packages/app/src/context/server-sdk.tsx": "c147f740ffaa2feb87364a70f535c5bd20893a7f54b82f66374a21407d066b91", + "packages/app/src/context/server-session.ts": "009fcdb6cd374c0f2cac53d37952afaa611f65371b43ff84f3654cffa8c56b18", + "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", + "packages/app/src/context/settings.tsx": "84911434b26eb3254cd399f0ab39396e22fc7cbd502d3d5007cb7eab773007e3", + "packages/app/src/context/split.tsx": "891aeb290b369b327a63ce548dcaab1daca08e41fab430648787be7f84e53ec0", + "packages/app/src/context/tabs.tsx": "6ca4ae0d85d5456cdda6f9aaec7e6aab0ca8aef0296b61f8bae7ee51da150a0a", + "packages/app/src/context/vault-panel.ts": "f91cb5fb49a9f0d46bd2a1b131f9a218b30147e2ad3617d2a2dc4f1e14599a53", + "packages/app/src/context/workbench.tsx": "69a44da2b69989bd2c67ebffdb49941841f3f4718d6a70d659c828b57928d9eb", + "packages/app/src/context/zoom-keybind.test.ts": "a1385c93639687a7dac7d21f8f02841d82a55e42465ce3836b97b16c82b52469", + "packages/app/src/design-polish.css": "26cf372ab527f44ac45f660f44dcae900f89c432fbd3db8684161058e13f364e", + "packages/app/src/desktop-menu.ts": "55481604f4fd5dcb74b0678347076c42fda7f928590d90bc2c3d1633bea8bc36", + "packages/app/src/entry.tsx": "da720fb72421c8e247a925fc627b24096e9d228dcff078cec81cc68f9bd37f2f", + "packages/app/src/i18n/ar.ts": "2b5e0c7f5a8c4ebc0305c956a8d9944c3cc86b1169bfe995d1b72a5bc9b5067d", + "packages/app/src/i18n/br.ts": "04f77c8d95bf83eead10db5166f5348cd9fb20940124fe117606c48ddeded9c5", + "packages/app/src/i18n/bs.ts": "798e3aa47f037f22cb0f0c54f96d1fd7ea257f303f87273dc09408b98b4aa314", + "packages/app/src/i18n/da.ts": "d2be9c55970e859d16751bd10fb61c15a5a5357550a13b3ede93736700cdce73", + "packages/app/src/i18n/de.ts": "84b0c448418236c529f65ec061d829c6de81a7c475df063f69a4163d00a205bf", + "packages/app/src/i18n/en.ts": "b54a1ad307d60b96d240a8bc138b944040eed66856929b52a79ea2a47a052666", + "packages/app/src/i18n/es.ts": "5dae047cff6fa6020c4e3ec0423fe210f02b7a2577039184bdbfbff3cd2a3c49", + "packages/app/src/i18n/fr.ts": "0e1bf25a4b3be9e7aa98df2a40c0b4a819cdecfb968d3724a2db7aa9b17a7b60", + "packages/app/src/i18n/ja.ts": "5cc5332076182e3f7da433d9677c8b9c1330874b0a2010ddf61a6e87b435e06c", + "packages/app/src/i18n/ko.ts": "bac03c8dc86207b10d26a137f0bf848d9c4da996fb39dd092f8f2fca140c803c", + "packages/app/src/i18n/no.ts": "655b405e831778237e2f56c5ab91e5b70fb28726a33374c173a7a54d8dea5572", + "packages/app/src/i18n/pl.ts": "0ba6615a07bd247958329692944a2492770407f9f34acdb12ab3083acddcead2", + "packages/app/src/i18n/ru.ts": "2c9d4cc273ff6259146e8031baf7fa501bfcba9895235982e21aaa9e726afec3", + "packages/app/src/i18n/th.ts": "55c4c7ef43be1832df368de69f626b714e4fe11e6e791b18b2c31c022bdc0037", + "packages/app/src/i18n/tr.ts": "01b9fe2566442d1c67019b440174cfe25424a0e9f082ea6566ed12d7d8ec065a", + "packages/app/src/i18n/uk.ts": "98c397aaaba1e66bd3e9fc6041b2a5ea0913077b203839dd16b8714db90d9a04", + "packages/app/src/i18n/zh.ts": "20c0ce167ed34d6fecf26d7f2c79839d9d7cbbf23286679ff019afd98f7a8d46", + "packages/app/src/i18n/zht.ts": "1a40c07bc44d2494a09990f2eb9cdb972ded8c74d053e4b9fd49fe4fb4c5df8a", + "packages/app/src/index.css": "c453a2430a68c1970a6b570be7840a4b07156d0238cfce8d8ffb8af3d6985b7d", + "packages/app/src/pages/error.tsx": "db9ba0847cfb205100ad6133cce30c6ea2ce983802af44947e6d2b746c2a6ec5", + "packages/app/src/pages/home-projects.test.ts": "a90155c37210a0e5b7a095bbfc3ba2d40681f02927d9726148a706bed5ba7891", + "packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7", + "packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850", + "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", + "packages/app/src/pages/home.tsx": "42c9b0c6fe79702fe5fe548fb1b25dc7bb57b1e6c2bf4225922781f8df86ff9d", + "packages/app/src/pages/home/home-sessions-view.tsx": "757e5db2ed0b62fae5a636cd2ab9404d37bf07640b518866d2da0e0b296903c1", + "packages/app/src/pages/layout-new.tsx": "32485d90c94b3137812ce7439da4dd1ba14689a3340bab6d02cf4877715ec9ed", + "packages/app/src/pages/layout.tsx": "a6e6574054f6d85c0d91c1236c9db99674b8d51876643896b0c34f77a28bc85a", + "packages/app/src/pages/layout/session-tab-avatar.tsx": "a69e8ba00edaffe0aee86e2c1f8bd82a2e6438e9752322a39a70ef9d57c76f00", + "packages/app/src/pages/layout/sidebar-items.tsx": "daf940fc66f34e5f83410a8580f7bb1790bedc599ab4d64fcda2301537b24865", + "packages/app/src/pages/layout/sidebar-workspace.tsx": "a0f8e3fbd16d09e783db2a7a09c686d3aa993436d882425d3436a59ec4adf94b", + "packages/app/src/pages/new-session.tsx": "8cfa0d719590e29fc185811c57306e80c54faf62a7d6049138d15c02e7c844a1", + "packages/app/src/pages/new-session/new-session-draft-controller.ts": "d48383233337a8095ef7a577dac021ae12c712c609fb546427ecda4b6e5299df", + "packages/app/src/pages/new-session/new-session-view.tsx": "230d8c19a76efab586e4c6f48cf271271268bbfeadf71b7fa54b08c4f7722970", + "packages/app/src/pages/session.tsx": "ca906c2d99a61cd4038c76a4ed943d6ed72f55c31fab3b37d1a6d274ae5ac8d9", + "packages/app/src/pages/session/composer/bug-dock-controller.test.ts": "fe3d68f2f2bb41b04ee5567cd95a9c01671a98fd21585788be9d4cbb01a4bc22", + "packages/app/src/pages/session/composer/bug-dock-controller.ts": "ab2c72a761e1992f300b6a0cecc9d0351b00a56e4be08567bcb22d3fcece78f7", + "packages/app/src/pages/session/composer/bug-dock.ts": "11eb1a1c911df8764162d47f07e5f135d94fcb622f8a2ad9b41a23233131ba58", + "packages/app/src/pages/session/composer/report-bug.test.ts": "d4dc012309c2171298852501a14a077c6fe8cdf5d25538dc84ddd1f8195d3a8c", + "packages/app/src/pages/session/composer/report-bug.ts": "bdc3b3dec38b11c7bfa5b867a246db2717e60e1d50692d6bddb3983c9e95e02b", + "packages/app/src/pages/session/composer/session-bug-dock.stories.tsx": "8244e22ad849861f9da24e559bc3166537e60331f15090d8b0fecbe45fd34d2c", + "packages/app/src/pages/session/composer/session-bug-dock.tsx": "e7fed3c5ab80527d69a106440833f795bbbafc5deeba3883902ede9fba612605", + "packages/app/src/pages/session/composer/session-composer-controls.ts": "b6942cd192eef5fb67343d54be35c986b735ca1e2185a20b29e226dd67f70e05", + "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "3209b9a8981cbaa7f0cd7bd09412b681c1dde108ba917ab54555f9144b6a443f", + "packages/app/src/pages/session/composer/session-composer-region.tsx": "7f8670d8bec3964527a916923426baa38e7b49090cd634c4101e6eccc81981a1", + "packages/app/src/pages/session/composer/session-composer-state.ts": "fcffa11d801309cf58f42a174143bf6eb3ea2b14f860ad91a8ffa4e097df5c16", + "packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts": "a1fe31fe4dfdf2914bc184030a0427bcbdfae0f3e7b146e1d6ee29ce31d85b64", + "packages/app/src/pages/session/composer/session-question-dock.helpers.ts": "b16282d0cf5d39152bd76f4ef8bac32f964f68143a3067765ae0827a5f90deed", + "packages/app/src/pages/session/composer/session-question-dock.tsx": "c6f865f2973fcfe56dc63ad60f3ead07c120595c2880eade70ada63d871238a1", + "packages/app/src/pages/session/composer/session-revert-dock.tsx": "3f2d030126a4c673731bac65406fd48797464ddc515444b891e39b0eb538e002", + "packages/app/src/pages/session/helpers.test.ts": "d9e2ba5625a2cdee7610d2f70b31da4972fd77e989db824d3f76b474691d9beb", + "packages/app/src/pages/session/helpers.ts": "ac1ee7592c8aa77b3b5026950638c07e1404531d2cb92c64e622d92dca6ee18b", + "packages/app/src/pages/session/session-panel-width.test.ts": "2b9daf379be0b54142dd791bed6bae6d215b2574176b3b3e4b1273db3baf433c", + "packages/app/src/pages/session/session-panel-width.ts": "8723cb2f980972ea9bf182240fdf154d131bf40e22fe563fd538a7005cff9192", + "packages/app/src/pages/session/session-side-panel-structure.test.ts": "c138fe905498c8326f459dccba61b146af6dfb12b78480303723b5a85046931a", + "packages/app/src/pages/session/session-side-panel.tsx": "d38bdee95cb8efd00f3350b503c701551cc050e2b8a9ca1f7379a0275a87a8cf", + "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "83753b3ec7227570523c2e0b9e82401fae7a20f3a23980bb40deaeb13304327b", + "packages/app/src/pages/session/timeline/message-timeline.tsx": "b4cbbaaa0b51358efa4924374a7c1bbf16c4e827641252eea5bb76d260b5a7a1", + "packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111", + "packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67", + "packages/app/src/pages/session/use-session-commands.tsx": "7af9a112ae60415572ab7c9b5d4e73b2f8ed9115858c695be675ffdf07c3df2b", + "packages/app/src/pages/session/v2/accumulate-diffs.test.ts": "e22895e339821ea468f8aeed53deec5244ceb9bd4371c815926ec19203258d07", + "packages/app/src/pages/session/v2/accumulate-diffs.ts": "f10a1e097a86f746f3fc2b861454f3b8f509e6895c54b7e3bd0317dbf3d14acf", + "packages/app/src/pages/session/v2/review-panel-v2-state.test.ts": "ac0cf1939c164948d9a0c54cc15076b30413612d872237e0fbd0912804c1ced0", + "packages/app/src/pages/session/v2/review-panel-v2-state.ts": "e27c3aa236d2862e3cbd87128c4e03e6d810974faaaadb12a7e04cdd3b531e1c", + "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f", + "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", + "packages/app/src/utils/amicode-bug-report.ts": "d55d9cecfe17719c0d4a0ca498345c7eff6fca1de6b645e4bc3fecb40f2f4645", + "packages/app/src/utils/amicode-fetch.ts": "ec62b502e6b8688cef0a2aa249715e847032265fd0e150d73ab3021a63db0e33", + "packages/app/src/utils/amicode-hidden-project.test.ts": "333ce7e13a2940268448db06fcfc8bf1733110b5311932820ade1dc9ab573180", + "packages/app/src/utils/amicode-hidden-project.ts": "d845a80ecad2d497528a5071f55cffab72e7b451f16d106cdb594f5bbd62c0ee", + "packages/app/src/utils/amicode-pane.ts": "46c18a2dc47c5db154f70272d25031e6834539ad03f6ab25613f082426f7c642", + "packages/app/src/utils/amicode-route-info.ts": "a0268a223e8de09fae9d4de8bee6a7774c14c87c6254cb4ed1d921db18587f44", + "packages/app/src/utils/chrome-dropdown.ts": "f8b878c5df4e1afd96b0751c73daaad0a8127411030724cb8f9a5089ebb29eac", + "packages/app/src/utils/global-clipboard.test.ts": "86f737c91542b8bd79b7872bc551ffd4a334c5d5a8bedfb2ae95052697dfc27f", + "packages/app/src/utils/global-clipboard.ts": "0b0125150e5c521ab5913699ced86b7395a1cbfb54549003b4558b9b4f421c13", + "packages/app/src/utils/pane-bridge.ts": "a8a8a40dc60a367086f3c4e4c561a289fb85d8b2086a8d62c2b66102de652ce4", + "packages/app/src/utils/serialize-session.test.ts": "f8fe318f195dca251eae3f4899f4a5881c3523522f59e02fef208588391f4048", + "packages/app/src/utils/serialize-session.ts": "c303c678500cdb64c7aa6928a0bd984c441a36705fe8152368a0e575cbcb459f", + "packages/app/src/utils/server-compat.ts": "01413e13b87485770eb3dbec0b882aec4c8247dafc4dc74b7b4669fa5b20cf1a", + "packages/app/src/utils/start-prompt.ts": "809d8ad0ab6de76a97503f8368ade25f771431be3f92eb8f039a2c65244ab20f", + "packages/app/src/utils/web-zoom.test.ts": "f373618138add504faa44dd6d2c9e3489b6e8dd0093ac6bb4f00f55307864e3d", + "packages/app/src/utils/web-zoom.ts": "014f99ce01219cac2bf22f30f20936d271aef17870e710250f6b1aa459317e44", + "packages/app/src/utils/webview-context-menu.ts": "6a8620ffa52ca745295cb9f9e23364505789c3424c2d91afdf3f5b9621ad812d", + "packages/app/src/wsl/settings-model.test.ts": "3304797c2779ca573f44eb25b758b67d66b2d7897432252375606680021390c3", + "packages/app/src/wsl/settings-model.ts": "35481fe3029714becf30b07024e0f849438c99c369a41b437b5208e48095cbf4", + "packages/core/schema.json": "cf1dc9d2ffd2383d473d96e7054258df0f719bb3a727f025cebf14b4d2c1e431", + "packages/core/src/config.ts": "b71c5920982666df2668de8aa04df85b245ddf95532c0c3df8fb6fb985402704", + "packages/core/src/database/database.ts": "9406173aa6cac6acd1c3ec306ae71942e12c8e2d68b7cd3d58a9a094539bc32f", + "packages/core/src/database/migration.gen.ts": "26804530f487a84d3d7abbb5381f4202ad726624415a4601925b302124e41743", + "packages/core/src/database/migration/20260813162312_shocking_karnak.ts": "f4e901f79e3f660b3809e336599a5ba7da7794ff595e6accf6218739c046ab68", + "packages/core/src/database/schema.gen.ts": "723e8c027dc573e86cf8a27795672de865befe0b00ee4165cb526e84b962873d", + "packages/core/src/flag/flag.ts": "55ffe731e35f8e045934463529e63bcd1e5f81df9fd69ebe585af85635ca72b0", + "packages/core/src/models-dev.ts": "dbbbe33f470e219d5fdaa6ec8e4998be95356e80756f29c18e3656562523923c", + "packages/core/src/observability/otlp.ts": "be20b97f84b2a5b4a1663fa3bed0b31c6c387950f7c28b76fbaa8525cd24d6e8", + "packages/core/src/permission.ts": "8c46b08adc9edd6bbfb90481b592ea6f00c40743d1ea0ee6a8b2e185dfd2de66", + "packages/core/src/permission/provider-saved.ts": "029f02a7120fde4ca1d499294f73f22ca8f0dbaba030a901f94093fb36674025", + "packages/core/src/permission/sql.ts": "c8c44e00cf957511282e35cbb192c0be36ed33d41d6b15e98d79eab4378661cd", + "packages/core/src/provider-permission.ts": "a672c1f40fa2efb0cd8923b0b7827b6debe770308bcaef692ad8589ba9b40685", + "packages/core/src/session.ts": "9b0942acb81373bd177ec544eaba74815369c0be55ad35c76a48b7676f2ce315", + "packages/core/src/session/runner/llm.ts": "e1c9da5f568cab64ebd49a467bff8e11a685306240b3faf672127663f1b02444", + "packages/core/src/tool/bash.ts": "4b56de5f3f7ad3ddbcf1ba756caff49f0a65f0fbc73811cbfc312ea716e0e447", + "packages/core/src/tool/edit.ts": "b1bd6df76da2c04681aef1c0d6e90e16e929b2c1ed09f6367a2bb9c655dc1d68", + "packages/core/src/tool/glob.ts": "3da75d862c37d35aafadf6d54b5d7ef3a61eefae049ac8c3ffe9cadfec818006", + "packages/core/src/tool/grep.ts": "db323a91b27727abcee372bf5403f322458ce456c9b581d4f905ab241bea6dd6", + "packages/core/src/tool/question.ts": "b4e114de69285147257a4be20d2ba54b17a42a5cb2daa178fea9fa4b8989887d", + "packages/core/src/tool/read.ts": "ceb37f787cc78a9973dec7c0f8c5fd647baeac979619741d0ff15167e13c6f29", + "packages/core/src/tool/webfetch.ts": "904846f51dbee2579a36c4856efbfe1442d4ce6a31d6a280cc387c390fd22bef", + "packages/core/src/tool/websearch.ts": "bd0502eeb9d6deeea975a21a592040b5b48abd6f2613e39dd6cfbd1a959c950a", + "packages/core/src/tool/write.ts": "4759d89e4b6cd5fa7d7de4ba091d2bf859d830cb15038828a15751c80fd9b0ec", + "packages/core/src/v1/session.ts": "53714b7754a3adecbed4e28d511f5c1ef97ae2fa379e3a07cc68714e1de8469e", + "packages/core/test/tool-question.test.ts": "e73b6d0aecdba94580e7d63e2da65cf17b475c81a66998d5225980cbb849e895", + "packages/schema/src/index.ts": "007e8bc04cc91c2c0aba3a78212c47c4fed6a8edd0d4be05286667d303448d11", + "packages/schema/src/prompt.ts": "eb3b42b98ef856f85f32154482b19de546272f1eabe552ee9207db030346291b", + "packages/schema/src/provider-permission.ts": "382d3b8c8e7bac64816d6a8cb0a84dff6e9a4bd1a921a653ebb20b23d4580e14", + "packages/schema/src/question.ts": "b334a3e7cea88e73ea11fef5bcdcbe258f57b586ef96142b760294faa6e4f0e3", + "packages/schema/src/v1/question.ts": "bee4686812c9492c39e4df34cbada59e63dc48240823d55fec61d6998dade5c7", + "packages/schema/src/v1/session.ts": "5804deda9cfcf2a015c759a6905b5bdfac74897239994c7facb246a60dbcf285", + "packages/schema/test/question.test.ts": "511f4fabf2841ed89e1fed9594f2e3a1be8eea5bcb50b680d762c49d6ef572d9", + "packages/sdk/js/src/v2/gen/sdk.gen.ts": "57597d710845cc0004ba778522c9bdf088fe9f7c23d7d9a3d947038a1fdc2674", + "packages/sdk/js/src/v2/gen/types.gen.ts": "394f4238a9284d9b250bd95104e5c2882a68b51b7c8838c164e5293d1bff52e1", + "packages/session-ui/package.json": "d0cd55d4641253e9715551a6e6f493ce692f641c51f6adeb19c8c896c336f2b8", + "packages/session-ui/src/components/group-parts.test.ts": "a31ce19009e1d62e75e83b682190a4613da0eed334420409ed73a9e21834ae84", + "packages/session-ui/src/components/markdown-file-refs.test.ts": "e2b1bed33629d3ab53c521f33266814088ea4122b6e64d6ab294eacc4cf15070", + "packages/session-ui/src/components/markdown-file-refs.ts": "b75bddeb565dc0163635fab8f84bdd26723742f9f82d06b6fd5991777b311032", + "packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83", + "packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399", + "packages/session-ui/src/components/markdown.css": "31f40458ea34a594112b2b148c8d49d0677e3f3dfe03b36ba1bcb6ab02c4151a", + "packages/session-ui/src/components/markdown.tsx": "89e132e62d20c873311c9781d0ca4d7cd1a7ff882d99f19c8696aafaf4a8f298", + "packages/session-ui/src/components/message-part-css.test.ts": "ab31eb47306e0826955a4471f18b263a77cd6f77f0eace7a46085645b2600c86", + "packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da", + "packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486", + "packages/session-ui/src/components/message-part-skill.ts": "d65a15dc39b8c0dece13aebe5e308426ae2dd758b07740251525ba9257654cf7", + "packages/session-ui/src/components/message-part.css": "74cbc0af6b451c846c95974f4cb23fe1d5d9c3deaff1f7ff886274c257a1b491", + "packages/session-ui/src/components/message-part.tsx": "62629470594ab6270267cb1d8ca48d43c1166d4f904f0cb8802cf48c27ae73ac", + "packages/session-ui/src/components/session-retry.tsx": "2cb1513d73275a269894f601e8c1d29bc790b14927134fe36177f2b371888721", + "packages/session-ui/src/components/session-turn.css": "792915f3373134eadf3cd51e1d4bfa88ec347e6b037b6caf60b639ec5e74fbd8", + "packages/session-ui/src/components/tool-error-card.tsx": "e27b89836b7c709120ac15fbffd2200038944058ea3506e61e5897ffb3537503", + "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a", + "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d", + "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea", + "packages/session-ui/src/v2/components/prompt-input/index.tsx": "3d1d7f1b435d63aa0bb4fcfe5ad3eb2e7d386d5c5c1974f3a15161b15ea9617e", + "packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx": "2aa16fef06bf6de8d5294a85ebd5fbbdfff3879f00f75e900d8049e33605593a", + "packages/session-ui/src/v2/components/session-review-v2.css": "166b71562d1bd7ae3c6a38dca854704fd903be29cc7088e2396e06c66070bbd5", + "packages/session-ui/src/v2/components/session-review-v2.tsx": "a683eb509c3ee927a7ed04ceffef3fd65129f812ac55f5b04c7e628bf0611831", "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", "packages/ui/src/amicode/amico-presence.css": "b3f345b607e318dcfbefcdc677fe21cc7d387b9946b0f9d625b0a842fe7e31b9", "packages/ui/src/amicode/amico-presence.stories.tsx": "33c854edd9a061d79c53e160ce5685ddb8cd41f4e205c6eb1b0dc96fec5017bb", diff --git a/packages/app-bundle/overlay/packages/app/CONTEXT.md b/packages/app-bundle/overlay/packages/app/CONTEXT.md new file mode 100644 index 00000000..4c4d68e6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/CONTEXT.md @@ -0,0 +1,130 @@ +# Amicode App — UI Structure + +The SolidJS surface of the Amicode extension. This context covers how the window is +laid out and how a researcher moves between chat and the supporting surfaces. It is a +glossary only — no implementation details, no routing tables, no component names. + +For the conversation/session runtime context, see the repo-root `CONTEXT.md`. + +## Language + +### The shell + +**Landing**: +What the window shows on launch. The Landing is always a fresh, empty chat — a new +composer awaiting a first prompt — never a dashboard or a resumed session. Prior +sessions and setup are one interaction away in the **Rail**, never in the way. +_Avoid_: Home, Home page, Dashboard, Start screen + +**Chat**: +The single persistent hub of the window — the composer and its conversation. Every +other surface opens beside or in front of the Chat; the Chat is never replaced by +another surface, only overlaid. There is no view in which the Chat is absent. +_Avoid_: Main view, editor, session pane (as the whole hub) + +**Context tree**: +The organic graph of what the agent is actually holding in mind, rendered inside the +**context side panel** (opened from the progress-circle ring button in the session +header). Root = amico at center, the session's turns orbiting it (one per user prompt, +however many assistant messages it spans), and the markdown, source, skills, and agents +each turn pulled into context clustering around their turn. Shared context sits between +the turns that recall it. Interactive: clicking a file node opens the real file (project +files as a session tab, vault files in the **Vault panel**). One per session view; absent +until the session holds context. On-demand — visible only when the context panel is open, +never persistent. See ADR 0004 (relocated from the former top panel of ADR 0003). +_Avoid_: Brain (that was the ambient, non-interactive strip), knowledge graph, minimap + +**Vault panel**: +The dismissible drawer that puts the vault — every attached mount and every file inside +it — in the interface. Read-only browsing with inline markdown/source rendering; opened +from the titlebar vault button, the command palette, or a **Context tree** click on a +vault node. +_Avoid_: Vaults tab (that is the mount *list* in status), file manager, explorer + +**Rail**: +The one narrow vertical strip of icons along the window's leading edge. Collapsed to +icons by default; each icon reveals its text label on hover. The Rail is the only +persistent navigation chrome — it replaces the former top chrome strip and the former +project-avatar strip, which no longer exist. +_Avoid_: Sidebar (the Rail is not the Panel), toolbar, activity bar + +**Rail surface**: +One thing the Rail can open, represented by a single icon. The navigation surfaces are +Chats, Projects, Run gallery, Pulse bank, and Library. A Rail surface is a destination, +not a document — it lists or collects things the researcher then acts on. +_Avoid_: Widget, card, tab + +**Panel**: +The dismissible drawer a **Rail surface** opens beside the **Chat**. Exactly one Panel +is open at a time; opening another replaces it; dismissing it returns the window to +Chat-only. Selecting an item inside a Panel acts in the Chat rather than navigating +away from it. +_Avoid_: Modal, page, route, flyout + +**Account zone**: +The cluster at the foot of the **Rail** for identity and configuration, distinct from +the navigation surfaces above it. Holds the researcher profile (About You), backend +Connections, solver/model Defaults, and Settings. These are set-and-forget surfaces, +not places a researcher browses. +_Avoid_: Settings menu (the zone is broader), footer + +### Ambient surfaces + +**Live-solve indicator**: +The always-visible readout that a solve is running, shown in the titlebar so it +survives every Chat and Panel change. Reports progress at a glance and, on click, jumps +to the Chat that owns the running solve. Its absence means nothing is solving. +_Avoid_: Now-solving card, status bar, progress toast + +**Setup nudge**: +The non-blocking prompt, shown alongside the first-run **Landing**, that reflects the +app-visible onboarding signal (profile completeness plus a dismiss flag today; richer +per-step progress — vault, Julia, connection — would require an extension→app bridge). +It persists until onboarding is complete and then disappears; it never gates the Chat. +_Avoid_: Onboarding modal, wizard (the wizard is the conversational interview; the nudge +only surfaces its state), setup wall + +## Relationships + +- The **Landing** is a **Chat**; the window never opens on anything else. +- The **Context tree** lives inside the context side panel and belongs to one session; + the **Vault panel** belongs to the window and opens beside whatever Chat is showing. A + Context-tree click on a vault node opens the Vault panel on that file — the two surfaces + reference each other but neither owns the other. +- Every **Rail surface** opens as a **Panel** beside the **Chat**; the Chat is never + replaced, only overlaid, and at most one Panel is open at a time. +- The **Rail** carries navigation surfaces above and the **Account zone** below; both + live in the same strip but are different in kind — one is browsed, one is configured. +- The **Live-solve indicator** and the **Setup nudge** are ambient: they belong to no + single Chat or Panel and persist across navigation until their condition clears + (nothing solving; onboarding complete). +- A **Setup nudge** reflects onboarding state; it does not own the onboarding flow, + which is the conversational overture interview conducted in the **Chat** itself. + +## Retired concepts + +These were real surfaces before the chat-first redesign and are intentionally gone. A +reader meeting them in older code or notes should map them forward: + +- **Home / Dashboard** (the aggregate widget page) → dissolved. Its widgets became + **Rail surfaces**, **Account zone** entries, in-Chat context, or first-run affordances. +- **Home top chrome strip** (the Home page's brand mark + defaults capsule + projects + flyout + connections + gear) → replaced by the **Rail** and **Account zone**. +- **Titlebar Home affordance** — the titlebar's `/`-pointing Home button is repurposed to + "new chat" once `/` is itself a Chat. The titlebar session/draft **tab strip** stays + (it tracks *open* sessions); the Chats **Rail surface** is full history — the overlap is + intentional, not a duplicate. +- **Project-avatar strip** (the always-visible `w-16` avatar rail in `sidebar-shell`) → + legacy-only; it is already absent when the redesign flag is on. Project switching lives + in the Projects **Rail surface**. +- **User-authored widget subsystem** (`amicode_author_widget`, "pin to dashboard", + the widget grid, `/amicode/dashboard`) → retired with the dashboard. Authoring and + pinning are removed, not re-homed; re-homing authored widgets as in-Chat artifacts is + a possible future, not part of this redesign. See ADR 0001. +- **Home cards** "Meet Amico" and "Jump back in" → folded into the first-run Landing and + the Chats surface respectively; they are no longer standalone surfaces. +- **Brain** (the ambient session map — first an inline timeline strip, then ADR 0002's + proposed chat-wide background) → superseded by the **Context tree** top panel. The + background direction was parked (ADR 0003); the strip row is deleted; the ambient engine + survives as a library only. **Glass** (the frosted card surface of the background design) + retires with it. diff --git a/packages/app-bundle/overlay/packages/app/docs/adr/0001-chat-first-landing.md b/packages/app-bundle/overlay/packages/app/docs/adr/0001-chat-first-landing.md new file mode 100644 index 00000000..704bfa2c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/docs/adr/0001-chat-first-landing.md @@ -0,0 +1,81 @@ +# 0001 — Chat is the landing; the dashboard dissolves into a rail + +- Status: Proposed +- Date: 2026-07-23 +- Deciders: Kate Bonner (Head of Product) + +## Context + +The Amicode window opened on a **Home** page: a top chrome strip plus an aggregate grid +of widgets (run gallery, pulse bank, library, "about you", "now solving", "meet amico", +"jump back in"). Chat lived on a separate route the researcher navigated to. Reaching a +new chat therefore cost a step, and the first thing a researcher saw was a dashboard of +everything rather than an invitation to act. + +The redesign already in flight on `kate/chat-redesign` had been pulling the new-session +screen toward a composer-as-hero, and auto-draft machinery to open an empty chat without +a session id already existed behind the `newLayoutDesigns` feature flag — though only +*inside* the directory-scoped session route, not at the root. The open question was +whether to keep the dashboard as a peer destination or commit to chat as the single hub. + +## Decision + +**The window lands on a fresh new chat on every launch, and the aggregate dashboard is +removed.** Chat becomes the one persistent hub. The widgets that earned a place are +re-homed onto a single vertical **Rail** of navigation surfaces (Chats · Projects · Run +gallery · Pulse bank · Library), with identity/config in an **Account zone** at the +Rail's foot. Each Rail surface opens a dismissible **Panel** beside the chat; the chat +is overlaid, never replaced, and at most one Panel is open at a time. Two widgets become +ambient: the running-solve readout becomes a **Live-solve indicator** in the titlebar, +and onboarding becomes a non-blocking **Setup nudge** on the first-run landing. + +The change is shipped as a single coherent cutover under the existing `newLayoutDesigns` +flag — not behind a new flag and not sliced for incremental rollout. The flag remains +the fallback to the legacy shell. + +See `packages/app/CONTEXT.md` for the vocabulary (Landing, Rail, Rail surface, Panel, +Account zone, Live-solve indicator, Setup nudge) and the retired concepts. + +## Consequences + +- The **Home** page, its **top chrome strip**, and the aggregate **Dashboard** are + retired. The titlebar's `/`-pointing Home button is repurposed to "new chat"; the + titlebar session tab strip stays (open sessions) alongside the Chats Rail surface + (history). The `w-16` **project-avatar strip** is legacy-only and already absent when + the flag is on. Older code and notes must map forward per `CONTEXT.md`. +- **The landing runs at `/`, which has no directory context**, unlike the existing + session-route auto-draft. The root landing must resolve a working directory first — + reusing the server-cwd fallback on a fresh install with no tracked project. +- **A shipped capability is retired:** dissolving the dashboard removes the home for the + user-authored-widget subsystem (`amicode_author_widget`, pin-to-dashboard, the widget + grid, `/amicode/dashboard`), so authoring and pinning are removed rather than re-homed. + Existing pinned widgets stop appearing. Re-homing authored widgets as in-Chat artifacts + is a possible future enhancement, deliberately out of this cutover. +- Fastest path to value: a researcher can type a prompt the instant the window opens. +- Discoverability of the collections (gallery, pulse bank, library) drops from + "on screen by default" to "one Rail click away." Accepted, because the Rail's + hover labels keep them nameable and the dashboard's density worked against focus. +- The server-driven widget data (`/amicode/*` routes) is reused; widgets are re-homed + into Panels, not rebuilt. +- Onboarding must be usable ambiently — the conversational overture interview plus a + Setup nudge — because there is no dashboard to host a setup wall. +- A single cutover means no partial state to maintain, but the whole shell changes at + once under the flag; review and QA happen against the flag as one unit. + +## Alternatives considered + +- **Keep an optional Dashboard surface** (a Rail icon reopening the grid). Rejected: + two ways to reach the same widgets, and it preserves the "dashboard of everything" + the redesign is trying to shed. +- **Grid as the default rail panel** (a soft home beside the chat). Rejected: leaves a + widget wall adjacent to the composer, undercutting "chat is the hero." +- **Resume the last chat on launch** instead of a fresh one. Rejected: makes the + landing inconsistent (sometimes chat, sometimes stale context) and diverges from the + reference; in-flight solves are recovered via the Live-solve indicator and the Chats + surface instead. +- **Two-tier rail** (Slack-style persistent project avatars beside the function rail). + Rejected: heavier chrome competing with the chat; the Projects surface covers fast + switching at one click. +- **New `chatFirstLayout` flag with a 3-slice rollout**, or a **prototype-first** + validation pass. Rejected for now in favor of one cutover under `newLayoutDesigns`; + revisit if the single cutover proves too large to review. diff --git a/packages/app-bundle/overlay/packages/app/docs/adr/0002-brain-as-chat-background.md b/packages/app-bundle/overlay/packages/app/docs/adr/0002-brain-as-chat-background.md new file mode 100644 index 00000000..7805ebc4 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/docs/adr/0002-brain-as-chat-background.md @@ -0,0 +1,126 @@ +# 0002 — The Brain becomes the Chat's living background; components float on legibility-floored glass + +- Status: Superseded by ADR 0003 (direction parked 2026-07-26; the Brain moved to the + context-tree top panel instead of becoming the chat background) +- Date: 2026-07-24 +- Deciders: Kate Bonner (Head of Product) +- Tracking: harmoniqs/opencode#56 + +## Context + +The amico **Brain** — the data-true, live map of a session's thought — currently renders +as an inline strip *inside* the message timeline (a native canvas engine; the `/brain.html` +iframe was already retired). It is one row among many, easy to scroll past, and it competes +with the messages rather than framing them. + +An earlier redesign (`kate/chat-brain-atmosphere`, six commits, never pushed or merged) +explored promoting the Brain to a chat-wide background behind *tiered per-bubble glass* +("atmosphere"), reaching a resolved design and a Phase-5 build. Kate reopened the design +fresh rather than continuing that branch. The open question: does the Brain stay an inline +timeline element, or become the **permanent background of the Chat** with every component +floating on frosted glass — and if so, how is legibility *guaranteed* when text sits on a +translucent surface over a moving graph? "Movement comes through" (translucency) and +"highly legible" (contrast) are in direct tension, and the earlier build hit exactly one +legibility failure: muted grey text not clearing WCAG-AA on its most translucent tier. + +This decision governs the **Amicode app UI** (the opencode fork). It sits under ADR 0001 +(chat-first shell): the Chat is the one persistent hub, and this ADR settles what the Chat +*looks like*. + +## Decision + +**The Brain becomes the permanent, full-bleed background of the Chat** — Landing, +conversation, and composer all sit on it. There is **one Brain**: the inline timeline strip +is absorbed into the background (one render loop, promoted from a timeline row to the room). +The **Rail, titlebar, and Panels stay solid**, framing the living pane. The Brain is a fixed, +viewport-anchored backdrop; cards scroll over a stationary Brain. + +**Every chat component floats as a frosted-glass card, and legibility is guaranteed by +construction — not by eye:** + +- **Blur** — a *high, constant* gaussian blur on every card. It provides perceptual calm and + kills the high-frequency motion detail that hurts reading. It is cheap (no per-frame logic) + and it is *not* what makes text legible. +- **Tint** — contrast comes entirely from a tint whose **opacity is pinned to clear the WCAG-AA + floor (with a small safety margin, not exactly at the floor) over a fixed reference frame** — + a synthetic Brain at peak bloom in which the sampled backdrop is the brightest palette value + **the Brain actually paints in that theme** (peak `#fff676` on dark; peak `#8f8000` on light — + the light-mode Brain never paints `#fff676`, so validating against it there would derive a + near-transparent tint that silently fails over the real light frame). Blur smooths the backdrop but does not darken it; a card over a bright-yellow + cluster is still on bright yellow until the tint pulls the composite toward the ink. The tint + set is **keyed to the chat's own theme** — the chat runs a theme independent of the app shell + (default oc-2) — so a chat-theme change re-keys the Glass and re-validates contrast. **Two + tints:** *standard* (prose, bubbles, composer) holds body text ≥4.5:1; *dense* (code, diffs, + run-plots) is more opaque and holds code/diff text ≥4.5:1 and graphical marks — syntax colors, + diff fills, plot lines — ≥3:1 (WCAG 1.4.11), so colored content stays crisp. + +**The Brain is data-true with an adaptive heartbeat.** Nodes light up as amico reads files, +runs tools, and touches the vault; the graph grows with the conversation, **densifying in place +within the fixed, viewport-anchored frame** — bounded by a node cap / recency window so a long +session neither scrolls the Brain nor grows its per-frame cost without limit. Motion follows +activity: a lively tempo when amico is working, ~8fps breathing at rest, and a **hard pause** +when the window is hidden or `prefers-reduced-motion` is set. At rest it shows a **sparse +seed** — a near-empty Landing whose graph grows *only* from the current session's real +thought, with **no cross-session persistence** (a fresh session opens nearly empty). One Brain +per window shows the **active** session; switching the titlebar session tab swaps the Brain to +that session's graph. + +**Pre-agreed perf fallback (non-negotiable mid-build):** the frosted look is protected. At +rest, full blur and full motion always apply. A frame-time monitor trips the ease when p95 +frame time stays over budget for a sustained window (with hysteresis before restoring, to +avoid oscillation): the Brain's *motion* eases (calm/slow) and returns to full tempo at rest. +If easing motion to a full stop still misses budget, the Brain **hard-pauses to a static +blurred field** — the terminal valve — but **blur fidelity never degrades.** + +**Build by reuse.** A new, well-named branch off `local/amicode` reuses the de-risked native +plumbing — the native Brain engine, the glass-token generator, and the mount wiring from the +earlier branch (design-neutral, already hardened by an adversarial port-fidelity review) — and +builds the *design* layer fresh. No iframe. Both light and dark themes are first-class from day +one. + +See `packages/app/CONTEXT.md` for the new vocabulary (**Brain**, **Glass**). + +## Consequences + +- The **inline brain strip** is retired as a timeline row; the timeline keeps only its text + shimmer (the existing working indicator — the cycling gerund + live elapsed shown while a turn + works). One Brain, one render loop. +- **No node-level interactivity is lost.** The strip's only affordance was a click-to-expand/ + collapse toggle (it was a collapsible row); the Brain engine has no node hit-testing. As an + always-present background the expand toggle is obviated, not removed from a capable surface. +- When a solid **Panel** covers the Chat, the Brain need not animate behind the occluded area — + a cheap perf saving, since Panels are opaque. +- The *standard* tint's opacity is **bounded below** by the contrast floor — it cannot be made + ultra-transparent, or muted grey text fails AA over the reference frame (the exact + failure the earlier build hit). Secondary/muted text therefore rides the *dense* tint or a + locally-dimmed zone. +- The Chat's cost profile changes: high-radius backdrop-blur recomputed per frame over an + animating canvas, across many cards, while scrolling, is the expensive combination. It must + pass a **laptop-webview 60fps gate** (the real target — headless Chromium is only a proxy); + the motion-ease fallback is the release valve. +- The **Rail, titlebar, and Panels are deliberately excluded.** Nav chrome stays rock-solid, + and dense Panel lists (Chats history, Run gallery) don't fight a moving background. +- The Landing (a fresh, empty Chat per ADR 0001) now opens on the sparse-seed Brain rather + than a blank surface — the window's first impression is the living background, calm and + nearly empty, not a widget wall. + +## Alternatives considered + +- **Keep the inline brain strip** (a timeline row). Rejected: it is one element among many; + the redesign's intent is the Brain as the room, not a row. +- **One calm reading lane / unified glass sheet** (Brain only in the margins). Rejected in + favor of per-component floating cards — motion lives in the gaps between cards, which is + where "movement comes through" *without* the cards themselves having to be see-through to it. +- **Dynamic contrast glass** (each card samples its backdrop per frame and adapts its tint). + Rejected: per-frame readback is the biggest perf risk and AA is hard to prove against every + frame; the static AA-floor tint is deterministic and cheap. +- **Quiet zone behind cards** (dim/slow the Brain under each card's footprint via per-card + masking). Rejected: real engine complexity for translucency the gaps already provide. +- **Breathing skeleton atlas at rest** (the full latent graph, dim, breathing). Rejected in + favor of the sparse seed — calmer, more literal, and it lets the floating cards dominate. +- **Protect motion and ease blur under load**, or **degrade only while scrolling**. Rejected: + the frosted look is the aesthetic being protected; motion is the acceptable give under load. +- **Rebuild the engine clean**, or **evolve the old `kate/chat-brain-atmosphere` branch**. + Rejected: reuse the de-risked plumbing (including its already-fixed port defects), but build + the design fresh on a new branch rather than inherit the old design structure or re-pay for + the engine port. diff --git a/packages/app-bundle/overlay/packages/app/docs/adr/0003-context-tree-top-panel.md b/packages/app-bundle/overlay/packages/app/docs/adr/0003-context-tree-top-panel.md new file mode 100644 index 00000000..bd6f3b12 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/docs/adr/0003-context-tree-top-panel.md @@ -0,0 +1,80 @@ +# 0003 — The knowledge graph moves to a top panel as the agent's context tree; the vault gets a first-class surface + +- Status: Accepted +- Date: 2026-07-27 +- Deciders: Kate Bonner (Head of Product), on Aaron Trowbridge's design feedback (2026-07-26) +- Supersedes: ADR 0002 (Brain as chat background — direction parked) + +## Context + +The amico **Brain** rendered as an inline strip inside the message timeline: one row among +many, easy to scroll past, non-interactive by design (`aria-hidden` canvas, no hit-testing), +and drawn over a **hardcoded sample of the armonissima vault** rather than anything the +agent is actually doing. ADR 0002 proposed promoting it to a chat-wide living background; +that direction was reviewed and parked. + +Aaron's feedback reframed what the graph is *for*: not ambience, but **orientation** — a +researcher watching the agent work wants to see *what the agent is holding in mind* and to +reach those artifacts directly. Two gaps followed from that framing: + +1. The graph showed a decorative vault skeleton, not the session's real context, and + nothing on it could be clicked. +2. The **vault itself** — the knowledge base the agent reads and writes — had no surface in + the interface at all. The app could list mount names (`/amicode/vaults`) but could not + browse or read a single note. + +## Decision + +**1. The graph moves out of the timeline into a pinned top panel** between the session +header and the chat, collapsible, one per session view. The inline brain strip row is +removed from the timeline (`TimelineRow.Brain` deleted); the ambient background direction +of ADR 0002 is not pursued. + +**2. The graph is redesigned as the agent's context tree.** An organic, Obsidian-like +graph (layout updated from the initial tidy tree — Kate, 2026-07-27): root = amico pinned +at center; the session's turns orbit it (roman-numeral plates carrying the prompt +excerpt — the atlas idea, restructured); the distinct markdown, source, skills, +agents, and web references each turn pulled into context cluster around their turn +under a deterministic force settle. Commits only — searches/globs +are transient scouting and never enter the tree. A leaf re-touched by a later turn is +**deduplicated into a thin recall link** back to the existing node, so the tree stays a map +of distinct context, not a log. Marathon sessions fold their oldest turns into one +"earlier" branch. + +**3. The tree is interactive — the graph is now a navigation instrument.** Nodes +hit-test; hover raises the label and hands the node to the host; **clicking a file node +opens the real file** — project files as a session file tab (the same flow as the file +tree), vault files in the Vault panel, landed directly on that note. Camera: auto-fit +until the user pans/zooms; wheel zooms about the cursor; double-click refits. The log's +hover-glance events (`amicode:brain-hover`) keep working against the tree. + +**4. The vault lives in the interface.** A read-only **Vault panel** (drawer beside the +Chat, per the CONTEXT.md Panel concept) lists every attached mount and every file inside +it, and renders markdown and source inline. Two new raw server routes back it — +`GET /amicode/vault-files` (recursive listing) and `GET /amicode/vault-file` (single read, +real-path traversal guard, size cap) — beside the existing `/amicode/vaults` mount list. +Entry points: a titlebar vault button (both titlebar variants), the command palette +("Toggle vault panel"), and context-tree clicks on vault nodes. + +## Consequences + +- `brain-engine.ts` stays as-is (the ambient engine remains a library; the strip component + is deleted). The context tree is a **separate, purpose-built engine** + (`context-tree-engine.ts`): declarative `setTree`, deterministic force-settle layout, + pointer interaction, + DPR-change-aware canvas. Both engines remain exempt from the single-yellow accent rule as + data visualizations; `#fff676` still marks only the live position. +- The tree derivation is pure and headless (`context-tree-data.ts`), unit-tested without + the sync store; `brain-ref` now carries the full file path so nodes can open files. +- The vault routes are read-only and refuse path escapes even through symlinks; binaries + are listed but marked unreadable, so the tree still shows the vault's true shape. +- Vault contents are proprietary knowledge, so browsing is a **local-researcher + capability, not a server API**: the routes refuse on any non-loopback bind (same + signal as the credential-mutation guard; `AMICO_VAULT_BROWSER=1` opts a shared + deployment in, `=0` forces off, `=public` serves only public vaults — the + hackathon-box mode). Per-mount browsability is **fail-closed by kind** (Aaron + 2026-07-27): personal and public browse by default; team/project/engagement — + and unknown kinds — ship dark until their marker says `browse = true`; + `browse = false` darkens any kind. The agent's read grants are unaffected. +- Keyboard reachability of individual canvas nodes is an open follow-up; every action the + canvas offers also exists via keyboard-reachable surfaces (file tree, Vault panel). diff --git a/packages/app-bundle/overlay/packages/app/docs/adr/0004-context-tree-to-side-panel.md b/packages/app-bundle/overlay/packages/app/docs/adr/0004-context-tree-to-side-panel.md new file mode 100644 index 00000000..8ba0a8c0 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/docs/adr/0004-context-tree-to-side-panel.md @@ -0,0 +1,54 @@ +# 0004 — Context tree relocates from the top panel to the context side panel + +- Status: Accepted +- Date: 2026-08-08 +- Deciders: Aaron Trowbridge (design), JJ Lee +- Supersedes: ADR 0003 (positioning clause only — interactivity, data model, vault browsability unchanged) + +## Context + +ADR 0003 moved the knowledge graph out of the message timeline into a pinned 192px top +panel between the session header and the chat. The intent was orientation: a researcher +watching the agent work sees what context it holds. In practice, the panel is persistent +visual noise — it consumes 192px of prime chat real estate on every session regardless of +whether anyone is looking at the graph. Aaron's recommendation (issue #264): remove it. + +The session already owns a **context side panel** (opened from the progress-circle ring +button in the session header's top-right corner). That panel shows context-window stats, +a role-breakdown bar, the system prompt, and raw messages — all quantitative views of +"what's in the agent's context." The graph is the *qualitative/navigational* counterpart +and belongs with them. + +## Decision + +**The context tree canvas moves from the pinned top panel into the context side panel, +rendered at the top of that panel above the stats grid.** The top panel is removed; the +192px it occupied returns to the chat timeline. + +Specifically: + +1. The `ContextTreePanel` top-panel component is deleted from `message-timeline.tsx`. +2. The `ContextTreeFrame` rendering logic (canvas + keyboard nav + hover-glance listener) + moves into `session-context-tab.tsx`, rendered as the first section. +3. The canvas keeps 192px height and fills the panel's width (the engine's `resize()` and + auto-fit camera already adapt to container size). +4. No per-section collapse — the panel itself is the dismiss gesture (click the ring + button again). The `localStorage` toggle (`amicode-context-tree-open`) is removed. +5. The `pause()`/`resume()` pattern keys on panel visibility instead of collapse state. +6. The `amicode:brain-hover` event listener moves with the canvas; when the panel is + closed, it is a no-op (paused engine). +7. `sessionHasContextItems` remains in use — it gates whether the graph section renders + inside the panel (no empty canvas on sessions with no committed context). + +## Consequences + +- `context-tree-engine.ts` and `context-tree-data.ts` are untouched — the engine is + container-size-aware and needs no layout rework. +- 192px of vertical space returns to the chat on every session. +- The graph is on-demand: visible when the researcher explicitly opens the context panel, + hidden otherwise. +- File-opening on node click continues to work identically (project files → session tab, + vault files → Vault panel). +- The `CONTEXT.md` glossary entry for "Context tree" is updated to reflect the new + location (inside the context side panel, no longer a "band folded into the session + header"). diff --git a/packages/app-bundle/overlay/packages/app/docs/design/agentic-surfaces.md b/packages/app-bundle/overlay/packages/app/docs/design/agentic-surfaces.md new file mode 100644 index 00000000..336531f8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/docs/design/agentic-surfaces.md @@ -0,0 +1,77 @@ +# Agentic surfaces — static elements generated from the researcher's work state + +- Status: Proposal for discussion (Kate × Aaron) — no implementation yet +- Date: 2026-07-27 +- Origin: Aaron Trowbridge's feedback (2026-07-26): "the potential to make static + elements agentically generated based on the user's current work state" + +## The idea + +Most of the interface outside the conversation is **authored once and shown to +everyone**: the new-session view, the setup nudge, empty states, panel headers, the +defaults capsule. Aaron's observation is that an agent product doesn't have to treat +these as fixed copy — the agent knows the researcher's work state (active problems, run +history, vault recency, calibration drift, what the last session ended on) and could +**compose these surfaces** the way it composes an answer. + +The context-tree top panel (ADR 0003) is the first surface of this kind already shipped: +its content is entirely derived from work state, with a fixed *frame* and generated +*content*. This document asks where else that pattern pays for itself. + +## Inventory — static elements that could be work-state-driven + +| Surface | Today | Agentically generated would mean | +|---|---|---| +| New-session view | Fixed problem list + copy | "You were mid-sweep on the CZ ladder; run 9 finished overnight — resume?" with the 2–3 next actions ranked from ledger + session history | +| Setup nudge | Profile completeness flag | Nudge text chosen from the actual missing step (vault, Julia, connection) and phrased against what the user tried last | +| Empty states (run gallery, pulse bank, library) | Generic copy | Seeded with the nearest real thing: last run, closest catalog pulse, most-recent paper | +| Session header context | Session title | One generated line of "where this thread left off" on resume | +| Composer placeholder | Fixed prompt copy | Rotates against work state ("ask about run 12's stagnation…") | +| Defaults capsule | Static solver defaults | Defaults proposed from the last N solves of this problem family | + +## Architecture sketch — three tiers, in order of trust + +1. **Derived (no LLM, deterministic).** Templates filled from ledger/vault/session + queries. Cheap, always fresh, testable — the context tree lives here. Most of the + table above can too, and this tier should be exhausted first. +2. **Generated-and-cached.** A background agent (the distiller cadence fits) writes + short surface copy into a per-user store; the UI renders it as data. Latency-free at + render time; staleness bounded by the cadence. The existing server-rendered widget + pipeline (`widgets-src/*` → `/amicode/widget-frame`) is the natural substrate — it + already solves sandboxing, CSP, and per-widget data plumbing. +3. **Live-generated.** Composed at render time by a model call. Highest freshness, + highest cost, needs skeleton states and a hard timeout fallback to tier-1 content. + Probably justified only for the new-session view, if at all. + +## Constraints that must hold + +- **Frames are fixed; content is generated.** Layout, tokens, and interaction patterns + stay authored (amicode-design-system governs them); the agent fills slots. No + agent-invented UI structure. +- **Fallback is the current static copy.** Every agentic surface degrades to exactly + what ships today — an empty work state must never produce a broken or blank surface. +- **Provenance visible on anything actionable.** If a surface proposes an action + ("resume the sweep"), it must say what it derived that from, and be wrong-safe: + clicking a stale suggestion can never destroy state. +- **No surprise tokens.** Tier 2/3 generation runs on explicit cadences or user action, + never silently per-render. + +## Open questions for Aaron + +1. Which surface first? The new-session view has the highest leverage (it's the landing + for every returning user), but empty states are the cheapest proof of the pattern. +2. Is tier 2 (cached generation on the distiller cadence) fresh enough, or does the + resume-context line need tier 3? +3. Should generated copy be visibly marked (e.g. the thought-color accent) so + researchers learn which parts of the interface are alive? +4. Does the widget kernel become the delivery mechanism for all of this, or is it + simpler to add a `GET /amicode/surface?id=…` route returning structured slots? + +## Relationship to shipped work + +- **Context tree (ADR 0003)** — tier 1, shipped. The template for "fixed frame, + work-state content." +- **Command zoom fixes** — unrelated mechanically, but they remove the "static chrome is + broken" noise that would undermine trust in smarter chrome. +- **Vault panel** — the read path any generated surface will cite when it references + vault knowledge. diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/chrome-surfaces.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/chrome-surfaces.spec.ts new file mode 100644 index 00000000..f48a3383 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/chrome-surfaces.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// amicode#105 chrome surfaces — AC button_flow_e2e_passing >= 3: +// 1. the vault button opens the POPULATED global drawer on home +// 2. sidebar-right toggles a SINGLE-PANE work column (pressed state truthful) +// 3. the status popover opens inside the viewport (no magic shift) +// ============================================================================ + +const sessionRoute = `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}` + +async function bootApp(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) +} + +async function mockVaultRoutes(page: Page) { + // Registered AFTER the base mock — last registration wins in Playwright. + await page.route("**/amicode/vaults", (route) => + route.fulfill({ json: { mounts: [{ id: "personal", kind: "personal", writable: true }] } }), + ) + await page.route("**/amicode/vault-files*", (route) => + route.fulfill({ + json: { ok: true, files: [{ path: "notes/todo.md", name: "todo.md", size: 42, readable: true }] }, + }), + ) +} + +test("vault button opens the populated drawer on home (global host)", async ({ page }) => { + await bootApp(page) + await mockVaultRoutes(page) + await page.goto("/") + await expectAppVisible(page.getByText("Open chat").first()) + + await page.getByRole("button", { name: "Open vault" }).click() + + const drawer = page.locator('[data-component="amico-vault-panel"]') + await expect(drawer).toBeVisible() + // populated, not a silent empty shell — the mocked tree renders (dirs + // start collapsed: unfold, then the file is there) + await drawer.getByRole("button", { name: "notes" }).click() + await expect(drawer.getByText("todo.md")).toBeVisible() + // and it closes from the same TITLEBAR button (the toggle is not one-way) + await page.getByRole("banner").getByRole("button", { name: "Close vault panel" }).click() + await expect(drawer).toBeHidden() +}) + +test("sidebar-right toggles a single-pane work column, pressed state truthful", async ({ page }) => { + await bootApp(page) + await page.goto(sessionRoute) + const toggle = page.getByRole("button", { name: "Toggle review" }) + await expect(toggle).toBeVisible() + + await toggle.click() + const column = page.locator("#review-panel") + await expect(column).toBeVisible() + await expect(toggle).toHaveAttribute("aria-expanded", "true") + // single-pane: the review file-list sidebar never renders (the split that + // squished the chat) — the aside must not exist even with the column open + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) + // bounded: the column is fixed-width and the chat is the flex REMAINDER — + // the pre-fix review pane took everything the chat left behind + const width = await page.evaluate(() => window.innerWidth) + const columnBox = (await column.boundingBox())! + expect(columnBox.width).toBeLessThanOrEqual(width * 0.6) + expect(columnBox.width).toBeLessThan(width / 2) + + await toggle.click() + await expect(column).toBeHidden() + await expect(toggle).toHaveAttribute("aria-expanded", "false") +}) + +test("status popover opens inside the viewport (no magic shift)", async ({ page }) => { + await bootApp(page) + await page.goto(sessionRoute) + const trigger = page.getByRole("button", { name: "Status" }) + await expect(trigger).toBeVisible() + + await trigger.click() + const body = page.locator('[data-slot="popover-body"]').first() + await expect(body).toBeVisible() + const box = (await body.boundingBox())! + const width = await page.evaluate(() => window.innerWidth) + // the old shift={-168} could land the panel off-anchor/clipped; honest + // anchoring keeps it fully inside the viewport + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(width) +}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts new file mode 100644 index 00000000..39ee2ca9 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +// Regression: on a fresh profile (no tracked projects in localStorage) the +// home "Open chat" CTA dead-ended silently — startWithPrompt fell through to +// openNewSession(), which needs the same newSessionProject() that just came +// back empty. It must instead fall back to the server's own working +// directory (GET /path → .directory) and start a draft there, tracking the +// directory as a project so the rest of the home page works from then on. +test("home 'Open chat' falls back to the server cwd on a fresh profile", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: [], + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + + // Deliberately NO localStorage seed — an empty tracked-project list is the + // regression condition (contrast: session-list-path-loading.spec.ts seeds it). + await page.goto("/") + // exact: true — the whole Meet-Amico card is also a button whose accessible + // name contains "Open chat"; we want the CTA inside it. + await page.getByRole("button", { name: "Open chat", exact: true }).click() + + // Navigates to a new-session draft instead of doing nothing. + await expect(page).toHaveURL(/\/new-session\?draftId=/) + + // And the server cwd is now a tracked project (the self-healing part). + const persisted = await page.evaluate(() => localStorage.getItem("opencode.global.dat:server") ?? "") + expect(persisted).toContain(fixture.directory) +}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/pane-boot-watch.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/pane-boot-watch.spec.ts new file mode 100644 index 00000000..7e481c37 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/pane-boot-watch.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +test("pane boot watch", async ({ page }) => { + test.setTimeout(120_000) + page.on("console", (m) => console.log("[frame-console]", m.text().slice(0, 200))) + page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 300))) + await page.setViewportSize({ width: 1440, height: 900 }) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] }, lastProject: { local: directory } }), + ) + }, fixture.directory) + await page.goto("/") + await expectAppVisible(page.getByText("Open chat").first()) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + const box = (await entry.boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + 40, 450, { steps: 6 }) + await page.mouse.move(1428, 450, { steps: 6 }) + await page.mouse.up() + await expect(page.locator("[data-pane]")).toHaveCount(1) + const frame = page.frameLocator("iframe[sandbox]").locator("body") + await expect.poll(async () => (await frame.innerText()).trim().length, { timeout: 30_000 }).toBeGreaterThan(0) + const text = await frame.innerText() + console.log("PANE BODY (first 400):", JSON.stringify(text.trim().slice(0, 400))) +}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/project-picker-recent-search.spec.ts new file mode 100644 index 00000000..c2e53b9c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] +const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) + +// The sixth project sits outside the five-item recent cap, so it is only reachable if the +// dialog hands every recent project to the list filter instead of a pre-truncated slice. +const OUTSIDE_CAP = "foxtrot-docs" + +// Dialog rows carry data-directory-path; the sidebar project list does not, so this +// scopes assertions to the picker instead of matching the sidebar entry of the same name. +const rows = (page: Page) => page.locator("[data-directory-path]") +const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) + +// amicode: skipped on the fork — this spec drives upstream's home-projects +// surface (the "Add project" button on the home page), which the fork does +// not render: NewHome (ADR 0001) exposes no add-project entry point and the +// legacy interface is compile-time retired (context/settings.tsx: +// newLayoutDesigns = createMemo(() => true)). The v1.18.12 five-cap + +// search-all logic itself ships intact in dialog-select-directory.tsx; +// re-enable once a v2 projects surface gains the entry point (harmoniqs#120). +test.skip(true, "fork v2 home has no add-project surface (see comment above)") + +async function openProjectDialog(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + fileList: () => [], + findFiles: () => [], + }) + await page.addInitScript((dirs) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + }, worktrees) + await page.goto("/") + const add = page.getByRole("button", { name: "Add project" }).first() + await expectAppVisible(add) + await add.click() + await expect(rows(page)).toHaveCount(5) + return page.getByRole("textbox").last() +} + +test("searches every recent project, not just the five most recent", async ({ page }) => { + const search = await openProjectDialog(page) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) + + await search.fill("foxtrot") + + await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) +}) + +test("still caps the idle recent list at five projects", async ({ page }) => { + await openProjectDialog(page) + + await expect(row(page, NAMES[4])).toHaveCount(1) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) +}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/review-line-comment.spec.ts new file mode 100644 index 00000000..724cbab1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/review-line-comment.spec.ts @@ -0,0 +1,171 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewLineCommentRegression" +const sessionID = "ses_review_line_comment_regression" +const title = "Review line comment regression" + +test.beforeEach(async ({ page }) => { + await openReview(page) +}) + +test("opens the comment editor when code is clicked", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const line = review.getByText("export const value = 'after'", { exact: true }) + await expectAppVisible(line) + await line.click() + + await expect(review.getByRole("textbox")).toBeVisible() +}) + +test("opens the comment editor when a line number is clicked", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const lineNumber = review.locator('[data-column-number="1"]').last() + await expectAppVisible(lineNumber) + await lineNumber.click() + + await expect(review.getByRole("textbox")).toBeVisible() +}) + +test("opens the comment editor for a line number range", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const start = review.locator('[data-column-number="1"]').last() + const end = review.locator('[data-column-number="3"]').last() + await expectAppVisible(start) + await expectAppVisible(end) + + const from = await start.boundingBox() + const to = await end.boundingBox() + if (!from || !to) throw new Error("Missing line number bounds") + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) + await page.mouse.down() + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2) + await page.mouse.up() + + await expect(review.getByRole("textbox")).toBeVisible() +}) + +test("shows a comment button when a line number is hovered", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const lineNumber = review.locator('[data-column-number="1"]').last() + await expectAppVisible(lineNumber) + + const comment = review.getByRole("button", { name: "Comment", exact: true }) + await expect(async () => { + await page.mouse.move(0, 0) + await lineNumber.hover() + await expect(comment).toBeVisible({ timeout: 500 }) + await comment.click({ timeout: 500 }) + }).toPass() + await expect(review.getByRole("textbox")).toBeVisible() +}) + +test("stages a submitted line comment in the prompt context", async ({ page }) => { + const requests: string[] = [] + page.on("request", (request) => { + if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`) + }) + + const review = page.locator('[data-component="session-review"]') + await review.getByText("export const value = 'after'", { exact: true }).click() + await review.getByRole("textbox").fill("Use the existing value instead") + await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click() + + await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() + await page.getByRole("tab", { name: "Session" }).click() + const context = page.getByText("Use the existing value instead", { exact: true }).last() + await expect(context).toBeVisible() + await expect(context.locator("..")).toContainText("review.ts:2") + expect(requests).toEqual([]) +}) + +async function openReview(page: Page) { + await page.setViewportSize({ width: 700, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: "proj_review_line_comment_regression", + worktree: directory, + vcs: "git", + name: "review-line-comment-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "review-line-comment-regression", + projectID: "proj_review_line_comment_regression", + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [ + { + file: "src/review.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: + "diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n", + }, + ], + pageMessages: () => ({ + items: [ + { + info: { + id: "msg_review_line_comment_regression", + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + }, + parts: [ + { + id: "prt_review_line_comment_regression", + sessionID, + messageID: "msg_review_line_comment_regression", + type: "text", + text: "Review this change.", + }, + ], + }, + ], + }), + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + // Changes tab + diff are flaky on CI (new layout may show review without tab click). + // Try to click Changes if present, but don't hard-fail if diff never fires. + const changesTab = page.getByRole("tab", { name: "Changes" }) + if ((await changesTab.count()) > 0) { + await changesTab.click().catch(() => {}) + try { + const diffResponse = await page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff", { + timeout: 10000, + }) + expect((await diffResponse.json()).data).toHaveLength(1) + } catch { + // diff may already be cached or tab click not needed — proceed to review + } + } + + const review = page.locator('[data-component="session-review"]') + await expectAppVisible(review) + // Expand the file's diff if collapsed (heading button) + const headingButton = review.getByRole("heading", { name: /review\.ts/ }).getByRole("button").first() + if ((await headingButton.count()) > 0) { + await headingButton.click().catch(() => {}) + } else { + await review.getByRole("button").first().click().catch(() => {}) + } +} diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts new file mode 100644 index 00000000..3eac0285 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { + const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] + const parts = ordinary.map((tool, index) => + toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), + ) + parts.push( + toolPart("prt_question_dismissed", "question", "error", questionInput(), { + error: "The user dismissed this question", + }), + toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), + toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), + ) + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + // Some ordinary tools (e.g. task/skill/mcp) are now rendered via dedicated + // cards and not as generic tool-error-cards. Count the actual rendered cards + // rather than assuming every ordinary tool produces a tool-error-card. + // Previously expected 10 (9 + dismissed) but now 7 render as generic cards. + await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(7) + await expect(page.getByText(/dismissed/i)).toBeVisible() + await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) + for (let index = 0; index < 7; index++) { + await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() + } +}) + +test("transitions shell and question through running error outcomes", async ({ page }) => { + const shellID = "prt_transition_error_shell" + const questionID = "prt_transition_error_question" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(shellID, "bash", "pending", { command: "exit 1" }), + toolPart(questionID, "question", "pending", questionInput()), + ], + { completed: false }, + ), + ], + }) + await timeline.waitForPart(shellID) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120) + await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send( + partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })), + 180, + ) + await timeline.send( + partUpdated( + toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }), + ), + 250, + ) + + await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i) +}) + +test("labels all web search provider variants", async ({ page }) => { + const parts = [ + toolPart( + "prt_search_parallel", + "websearch", + "completed", + { query: "parallel" }, + { metadata: { provider: "parallel" } }, + ), + toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }), + toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() + await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() + await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() +}) + +function questionInput() { + return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } +} + +function errorInput(tool: string) { + if (tool === "bash") return { command: "exit 1" } + if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } + if (tool === "apply_patch") return { files: ["src/error.ts"] } + if (tool === "webfetch") return { url: "https://example.com" } + if (tool === "websearch") return { query: "failure" } + if (tool === "task") return { description: "Fail task", subagent_type: "explore" } + if (tool === "skill") return { name: "failure" } + return { target: "failure" } +} diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts new file mode 100644 index 00000000..c3750c0e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -0,0 +1,164 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const server = "http://127.0.0.1:4096" +const sessionA = session("ses_tab_a", "Tab A session") +const sessionB = session("ses_tab_b", "Tab B session") +const sessionC = session("ses_tab_c", "Tab C session") +const unresolvedSessionID = "ses_tab_unresolved" + +test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: sessionB }, + ]), + ) + }, + { server, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`) + await expect(linkB).toBeVisible() + const box = await linkB.boundingBox() + if (!box) throw new Error("tab link has no bounding box") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + + // Navigation must happen on mousedown, before the button is released. + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await page.mouse.up() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +test("keyboard navigation follows the visible tab order", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, unresolved, sessionC }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: unresolved }, + { type: "session", server, sessionId: sessionC }, + ]), + ) + }, + { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` + await page.goto(hrefA) + // The unresolved tab (ses_tab_unresolved) correctly hangs (never resolves) and + // should be filtered from visible tabs in the titlebar. On CI the mock + // occasionally leaks it as a third visible slot due to race — accept 2 or 3 + // but still assert the target tab exists. This was red on local/amicode + // (2 vs 3) for a while. + await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(3) + await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() + + await page.keyboard.press("Control+Alt+ArrowRight") + + await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, title: string) { + return { + id, + slug: id, + projectID: "project-tabs", + directory: "C:/tab-project", + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServer(page: Page) { + const sessions = [sessionA, sessionB, sessionC] + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== server) return route.fallback() + if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) + return new Promise(() => {}) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) + return json(route, { data: [], cursor: {} }) + const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) + if (byId) return json(route, byId) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: sessionA.projectID, + worktree: sessionA.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: sessionA.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/workbench-drops.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/workbench-drops.spec.ts new file mode 100644 index 00000000..c7ba48bc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/workbench-drops.spec.ts @@ -0,0 +1,197 @@ +import { test, expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// Workbench drops — S2 gate (spec-20260731-110956-workbench-panes-refactor): +// accidental_splits == 0 (sloppy drags change nothing) +// drop_targets_are_explicit == 1 (only rails/strips/panel accept; content rejects) +// duplicate_or_lost_tabs_after_move == 0 (incl. fault injection: dead pane boot) +// ============================================================================ + +const sourceRoute = `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}` +const targetRoute = `/${base64Encode(fixture.directory)}/session/${fixture.targetID}` + +async function boot(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + await expectAppVisible(page.getByText("Open chat").first()) +} + +/** HTML5 drag helper: from a locator's center to (x, y). */ +async function drag(page: Page, from: ReturnType, to: { x: number; y: number }) { + const box = (await from.boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 20, box.y + 60, { steps: 6 }) + await page.mouse.move(to.x, to.y, { steps: 10 }) + await page.mouse.up() +} + +const mainStripTabs = (page: Page) => page.locator("[data-split-main] .no-scrollbar div[data-active], [data-split-main] .no-scrollbar .tab") +const stripTabCount = (page: Page) => page.locator(".no-scrollbar div[data-active]").count() +const paneFrame = (page: Page) => page.frameLocator("iframe[sandbox]") + +/** Split via panel: drag the source session's panel entry onto the right rail. */ +async function splitFromPanel(page: Page) { + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + await expect(entry).toBeVisible() + await drag(page, entry, { x: page.viewportSize()!.width - 12, y: 450 }) + await expect(page.locator("[data-pane]")).toHaveCount(1) + // the pane boots its own app instance — slow in dev (~8s cold vite). Its + // strip rendering doubles as the drop model's boot-complete signal (its + // first tabs-changed report to the mirror lands in the same boot). + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]").first()).toBeVisible({ timeout: 25_000 }) +} + +test.beforeEach(async ({ page }) => { + test.setTimeout(30_000) + page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 300))) + page.on("console", (m) => { + if (m.type() === "error" || m.text().includes("workbench")) console.log("[console]", m.text().slice(0, 200)) + }) + await page.setViewportSize({ width: 1440, height: 900 }) +}) + +test("S2.1 accidental_splits == 0: a sloppy drag into content changes nothing", async ({ page }) => { + await boot(page) + await page.goto(sourceRoute) + const tab = page.locator(".no-scrollbar div[data-active]").first() + await expect(tab).toBeVisible() + const before = await stripTabCount(page) + await drag(page, tab, { x: 400, y: 500 }) + await expect(page.locator("[data-pane]")).toHaveCount(0) + expect(await stripTabCount(page)).toBe(before) +}) + +test("S2.2 drop_targets_are_explicit: the main content area rejects drops", async ({ page }) => { + await boot(page) + await page.goto(targetRoute) + await splitFromPanel(page) + const tab = page.locator(".no-scrollbar div[data-active]").first() + const before = await stripTabCount(page) + // dead-center of the main content — not a strip, not a rail, not the panel + await drag(page, tab, { x: 720, y: 620 }) + expect(await stripTabCount(page)).toBe(before) + // and the pane keeps its own single tab + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]")).toHaveCount(1) +}) + +test("S2.3 panel entries are drag sources: panel → right rail splits with that session", async ({ page }) => { + await boot(page) + await page.goto(targetRoute) + await splitFromPanel(page) + // the pane shows the dragged session's tab + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]")).toHaveCount(1) + // and the main strip is untouched + expect(await stripTabCount(page)).toBe(1) +}) + +test("S2.4 move main → pane: the tab leaves the main strip and activates in the pane", async ({ page }) => { + await boot(page) + // main strip holds only the TARGET tab; the source session splits from the panel + await page.goto(targetRoute) + expect(await stripTabCount(page)).toBe(1) + await splitFromPanel(page) + + // drag the main tab (target session) onto the pane's strip region + const tab = page.locator(".no-scrollbar div[data-active]").first() + const paneBox = (await page.locator("[data-pane]").boundingBox())! + await drag(page, tab, { x: paneBox.x + paneBox.width / 2, y: paneBox.y + 18 }) + + // the target tab left the main strip; the pane now holds source + target + expect(await stripTabCount(page)).toBe(0) + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]")).toHaveCount(2) + // the moved session is the pane's visible content + await expect(paneFrame(page).getByText(fixture.expected.targetTitle).first()).toBeVisible() +}) + +test("S2.5 dock pane → main strip: the tab comes home", async ({ page }) => { + await boot(page) + await page.goto(targetRoute) + await splitFromPanel(page) + + // drag the pane's tab back onto the main strip. NOTE: synthetic (CDP) + // drags don't initiate HTML5 DnD inside the sandboxed pane iframe — a + // platform limit, not app behavior. So the test exercises the pane's OWN + // wiring seam-for-seam: dispatch dragstart on the pane's tab (fires its + // wired listener → posts drag-tab-start to the parent), then drop on the + // main strip (fires the parent's resolution → move executes). + const paneTab = paneFrame(page).locator(".no-scrollbar div[data-active]").first() + await expect(paneTab).toBeVisible() + const path = await paneTab.evaluate((el) => { + const href = el.querySelector("a")?.getAttribute("href") ?? "" + el.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true })) + return href + }) + expect(path).toContain("/session/") + + const mainStrip = page.locator("[data-split-main] .no-scrollbar").first() + const stripBox = (await mainStrip.boundingBox())! + await mainStrip.evaluate((el, box) => { + const row = el.closest("div.flex-row") ?? el + row.dispatchEvent( + new DragEvent("dragover", { bubbles: true, cancelable: true, clientX: box.x + 120, clientY: box.y + box.height / 2 }), + ) + row.dispatchEvent( + new DragEvent("drop", { bubbles: true, cancelable: true, clientX: box.x + 120, clientY: box.y + box.height / 2 }), + ) + }, stripBox) + + await expect.poll(() => stripTabCount(page), { timeout: 5000 }).toBe(2) +}) + +test("S2.6 same-session singleton: dropping an already-open session focuses it, never duplicates", async ({ page }) => { + await boot(page) + await page.goto(sourceRoute) + // the source session is open in main; drag its PANEL entry to the right rail + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + await drag(page, entry, { x: page.viewportSize()!.width - 12, y: 450 }) + await page.waitForTimeout(800) + // no split happened — the existing tab was focused instead + await expect(page.locator("[data-pane]")).toHaveCount(0) + expect(await stripTabCount(page)).toBe(1) +}) + +test("S2.7 fault injection: a pane that never boots cannot swallow a tab", async ({ page }) => { + await boot(page) + // main shows the TARGET session — the SOURCE session (dragged below) must + // not already be open, or the rail singleton gate focuses instead of splitting + await page.goto(targetRoute) + // block the pane iframe's app document — the pane boots empty and the + // self-heal guard will merge it; a dropped tab must survive in main. + await page.route("**/*", (route) => { + const url = route.request().url() + if (url.includes("amicode_pane=")) return route.abort() + return route.fallback() + }) + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + await drag(page, entry, { x: page.viewportSize()!.width - 12, y: 450 }) + await expect(page.locator("[data-pane]")).toHaveCount(1) + + // drag the main tab toward the dead pane's strip region + const tab = page.locator(".no-scrollbar div[data-active]").first() + const paneBox = (await page.locator("[data-pane]").boundingBox())! + await drag(page, tab, { x: paneBox.x + paneBox.width / 2, y: paneBox.y + 18 }) + + // nothing was lost into the void: the tab is still open in main + expect(await stripTabCount(page)).toBe(1) + await expect(page.locator(".no-scrollbar div[data-active]")).toHaveCount(1) +}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/regression/workbench-panes.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/regression/workbench-panes.spec.ts new file mode 100644 index 00000000..10c4b16e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/regression/workbench-panes.spec.ts @@ -0,0 +1,154 @@ +import { test, expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// Workbench panes — S1 gate (spec-20260731-110956-workbench-panes-refactor): +// main_column_width_preserved == 1 +// content_baseline_offset_px <= 2 +// tab_labels_truncate_cleanly == 1 +// sessions_panel_hideable == 1 +// Every metric is asserted headlessly; failure screenshots land in +// e2e/test-results (the runner archives them). +// ============================================================================ + +const sessionRoute = `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}` + +async function bootTwoSessions(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + // Seed the project into the persisted server store (the app otherwise boots + // to the empty-projects home and the sessions never surface). + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + // This build's home is the dashboard — it never lists session titles, so + // readiness is the dashboard itself; session tabs appear once routed to. + await expectAppVisible(page.getByText("Open chat").first()) +} + +/** Drag the first strip tab onto the right edge rail — the split gesture. */ +async function splitViaRightRail(page: Page) { + const tab = page.locator(".no-scrollbar div[data-active]").first() + await expect(tab).toBeVisible() + const box = (await tab.boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 40, 450, { steps: 10 }) + await page.mouse.move(page.viewportSize()!.width - 12, 450, { steps: 10 }) + await page.mouse.up() + await expect(page.locator("[data-pane]")).toHaveCount(1) +} + +const paneFrame = (page: Page) => page.frameLocator("iframe[sandbox]") + +test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) +}) + +test("S1.1 main_column_width_preserved: main keeps its floor and the row fills the window", async ({ page }) => { + await bootTwoSessions(page) + await page.goto(sessionRoute) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + + await splitViaRightRail(page) + + const main = await page.locator("[data-split-main]").boundingBox() + expect(main).not.toBeNull() + expect(main!.width).toBeGreaterThanOrEqual(280) + + // the row fills the window width — no dead space after the pane (FM2) + const row = await page.locator("div.h-full.w-full.min-h-0.min-w-0.flex.flex-row").boundingBox() + expect(row).not.toBeNull() + expect(Math.abs(row!.width - 1440)).toBeLessThanOrEqual(2) + + // sash drag keeps the floor + const sash = page.locator(".cursor-col-resize").first() + const sb = (await sash.boundingBox())! + await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2) + await page.mouse.down() + await page.mouse.move(sb.x - 200, sb.y + sb.height / 2, { steps: 6 }) + await page.mouse.up() + const main2 = await page.locator("[data-split-main]").boundingBox() + expect(main2!.width).toBeGreaterThanOrEqual(280) + + // merge restores the single full-width view + await page.locator("button[aria-label='Merge pane back']").click() + await expect(page.locator("[data-pane]")).toHaveCount(0) +}) + +test("S1.2 content_baseline_offset_px <= 2: both sides' chrome and content align", async ({ page }) => { + await bootTwoSessions(page) + await page.goto(sessionRoute) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + await splitViaRightRail(page) + + // both titlebar strips at the same height + const mainStrip = await page.locator(".no-scrollbar").first().boundingBox() + const paneStrip = await paneFrame(page).locator(".no-scrollbar").first().boundingBox() + expect(mainStrip).not.toBeNull() + expect(paneStrip).not.toBeNull() + expect(Math.abs(mainStrip!.y - paneStrip!.y)).toBeLessThanOrEqual(2) + + // session headers (the content's first heading) at the same height + const mainHeader = await page.getByText(fixture.expected.sourceTitle).first().boundingBox() + const paneHeader = await paneFrame(page).getByText(fixture.expected.sourceTitle).first().boundingBox() + expect(mainHeader).not.toBeNull() + expect(paneHeader).not.toBeNull() + expect(Math.abs(mainHeader!.y - paneHeader!.y)).toBeLessThanOrEqual(2) +}) + +test("S1.3 tab_labels_truncate_cleanly: pane tab labels ellipsize, never clip mid-glyph", async ({ page }) => { + await bootTwoSessions(page) + // the long-titled session exercises truncation + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + await splitViaRightRail(page) + + const paneTabLabel = paneFrame(page).locator(".no-scrollbar div[data-active] a span.truncate").first() + await expect(paneTabLabel).toBeVisible() + const metrics = await paneTabLabel.evaluate((el) => { + const cs = getComputedStyle(el) + return { textOverflow: cs.textOverflow, overflow: cs.overflow, whiteSpace: cs.whiteSpace, clipped: el.scrollWidth > el.clientWidth } + }) + expect(metrics.textOverflow).toBe("ellipsis") + expect(metrics.overflow).toBe("hidden") + expect(metrics.whiteSpace).toBe("nowrap") + // truncation IS happening (content exceeds the box) and CSS handles it — no mid-glyph clip + expect(metrics.clipped).toBe(true) +}) + +test("S1.4 sessions_panel_hideable: the workbench panel hides, persists across reload, and returns", async ({ page }) => { + await bootTwoSessions(page) + await page.goto(sessionRoute) + + // the workbench sessions panel shows on session routes, listing sessions + const panel = page.locator("[data-workbench-panel]") + await expect(panel).toBeVisible() + await expect(panel.getByText(fixture.expected.sourceTitle).first()).toBeVisible() + + const sidebarToggle = page.getByRole("button", { name: "Toggle sidebar" }) + await sidebarToggle.click() + await expect(panel).toBeHidden() + + await page.goto(sessionRoute) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + await expect(panel).toBeHidden() + + await sidebarToggle.click() + await expect(panel).toBeVisible() +}) diff --git a/packages/app-bundle/overlay/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app-bundle/overlay/packages/app/e2e/smoke/session-timeline.spec.ts new file mode 100644 index 00000000..e2d4bdc1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/smoke/session-timeline.spec.ts @@ -0,0 +1,739 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "./session-timeline.fixture" +import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" +import { mockOpenCodeServer } from "../utils/mock-server" +import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits" + +const forbiddenText = ["Load details", "Show earlier steps"] + +type SmokeState = { + ids: string[] + visibleIds: string[] + messageIds: string[] + visibleMessageIds: string[] + topVisibleId?: string + signature: string + scrollTop: number + scrollHeight: number + clientHeight: number + errorToasts: string[] + forbiddenText: string[] +} + +type SmokeWindow = Window & { + __timelineSmokeState?: () => SmokeState + __timelineSmokeErrorToasts?: string[] + __timelineSmokeForbiddenText?: string[] +} + +test.describe("smoke: session timeline", () => { + test.setTimeout(240_000) + + test("keeps the visible message fixed while prepending history", async ({ page }) => { + const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + messageDelay: 3_000, + onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }), + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await pointAtTimeline(page) + const deadline = Date.now() + 120_000 + while (!requests.some((request) => request.before && request.phase === "start")) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + for (let index = 0; index < 12; index++) { + await page.mouse.wheel(0, -120) + await page.waitForTimeout(20) + } + const keys = await scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-part-id]")] + .filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((row) => row.dataset.timelinePartId) + .filter((id): id is string => !!id) + .slice(0, 3) + }) + expect(keys.length).toBeGreaterThan(0) + const positions = () => + scroller.evaluate((element, keys) => { + const top = element.getBoundingClientRect().top + return Object.fromEntries( + keys.map((key) => { + const row = element.querySelector(`[data-timeline-part-id="${key}"]`) + if (!row) throw new Error(`Missing stable timeline key: ${key}`) + return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio] + }), + ) + }, keys) + const before = await positions() + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + + await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true) + await waitForTimelineStable(page) + await expect.poll(positions).toEqual(before) + }) + + test("preserves the timeline gap above the composer", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await waitForTimelineStable(page) + + const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]') + await expect(spacer).toBeVisible() + expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + }) + + test("paints cached session tabs at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expectSessionTitle(page, fixture.expected.targetTitle) + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const last = fixture.expected.targetMessageIDs.at(-1)! + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = [] + const firstPaintNodes = new WeakSet() + let firstPaint = false + let removedFirstPaintNodes = 0 + let running = true + new MutationObserver((records) => { + if (!firstPaint || !running) return + records.forEach((record) => + record.removedNodes.forEach((node) => { + if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1 + if (!(node instanceof Element)) return + node.querySelectorAll("*").forEach((element) => { + if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1 + }) + }), + ) + }).observe(document.documentElement, { childList: true, subtree: true }) + const sample = () => { + if (!running) return + setTimeout(() => { + if (!running) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + .filter((id) => ids.has(id)) + const bottom = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) + if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { + firstPaint = true + root.querySelectorAll("[data-timeline-key]").forEach((row) => { + const rect = row.getBoundingClientRect() + if (rect.bottom <= view.top || rect.top >= view.bottom) return + firstPaintNodes.add(row) + row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element)) + }) + } + } + requestAnimationFrame(sample) + }, 0) + } + ;( + window as Window & { + __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } + } + ).__sessionTabPaint = { + samples, + removed: () => removedFirstPaintNodes, + stop: () => { + running = false + }, + } + requestAnimationFrame(sample) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + ( + window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } } + ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0), + ) + await page.waitForTimeout(200) + const first = await page.evaluate(() => { + const probe = ( + window as Window & { + __sessionTabPaint?: { + samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> + removed: () => number + stop: () => void + } + } + ).__sessionTabPaint! + probe.stop() + return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } + }) + expect(first.first?.last).toBe(true) + expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + expect(first.removed).toBe(0) + }) + + test("paints a cold session tab at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) + await expectSessionTitle(page, fixture.expected.sourceTitle) + const last = fixture.expected.targetMessageIDs.at(-1)! + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] + const sample = () => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const spacer = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + samples.push({ + destination: messages.some((element) => ids.has(element.dataset.messageId!)), + last: messages.some((element) => element.dataset.messageId === last), + bottomError: spacer ? spacer.bottom - view.bottom : undefined, + }) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + } + ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples + requestAnimationFrame(() => setTimeout(sample, 0)) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some( + (sample) => sample.destination, + ), + ) + const result = await page.evaluate(() => { + const samples = ( + window as Window & { + __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> + } + ).__coldTabSamples! + return samples.find((sample) => sample.destination)! + }) + expect(result.last).toBe(true) + expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + }) + + test("renders seeded timeline in order while paging through history", async ({ page }) => { + const errors = trackPageErrors(page) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await selectHomeProject(page, fixture.project.name) + await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) + await expectSessionReady(page) + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + const expectedPartIDs = fixture.expected.targetPartIDs + const expectedMessageIDs = fixture.expected.targetMessageIDs + await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) + await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + + const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`) + const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]') + const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shellSubtitle).toHaveCount(0) + await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "false") + await expect(shellSubtitle).toHaveText("bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellSubtitle).toHaveCount(0) + }) +}) + +async function configureSmokePage(page: Page, directory: string) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }) + + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { + local: [{ worktree: directory, expanded: true }], + }, + lastProject: { + local: directory, + }, + }), + ) + }, directory) + + await page.addInitScript(() => { + const smoke = window as SmokeWindow + smoke.__timelineSmokeErrorToasts = [] + smoke.__timelineSmokeForbiddenText = [] + const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]" + const idsOf = (el: HTMLElement) => + [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id) + + smoke.__timelineSmokeState = () => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row], [data-session-title]"), + ) + if (!scroller) { + return { + ids: [], + visibleIds: [], + messageIds: [], + visibleMessageIds: [], + topVisibleId: undefined, + signature: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + errorToasts: smoke.__timelineSmokeErrorToasts ?? [], + forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], + } + } + + const ids: string[] = [] + const visibleIds: string[] = [] + const scrollerRect = scroller.getBoundingClientRect() + let topVisibleId: string | undefined + for (const el of scroller.querySelectorAll(partSelector)) { + const next = idsOf(el) + ids.push(...next) + + const rect = el.getBoundingClientRect() + if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) { + if (!topVisibleId) topVisibleId = next[0] + visibleIds.push(...next) + } + } + + const messageIds: string[] = [] + const visibleMessageIds: string[] = [] + const rows = [...scroller.querySelectorAll("[data-message-id]")].map((el) => { + const rect = el.getBoundingClientRect() + const id = el.dataset.messageId + if (id) { + messageIds.push(id) + if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id) + } + return { + id, + top: Math.round(rect.top), + bottom: Math.round(rect.bottom), + } + }) + const signature = JSON.stringify({ + top: Math.round(scroller.scrollTop), + height: Math.round(scroller.scrollHeight), + rows, + ids, + }) + + return { + ids, + visibleIds, + messageIds, + visibleMessageIds, + topVisibleId, + signature, + scrollTop: Math.round(scroller.scrollTop), + scrollHeight: Math.round(scroller.scrollHeight), + clientHeight: Math.round(scroller.clientHeight), + errorToasts: smoke.__timelineSmokeErrorToasts ?? [], + forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], + } + } + let recordFrame: number | undefined + const record = () => { + for (const toast of document.querySelectorAll('[data-component="toast"][data-variant="error"]')) { + const text = toast.textContent?.trim() + if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text) + } + const text = document.body?.textContent ?? "" + for (const value of ["Load details", "Show earlier steps"]) { + if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) { + smoke.__timelineSmokeForbiddenText!.push(value) + } + } + } + const start = () => { + const root = document.documentElement ?? document.body + if (!root) return + new MutationObserver(() => { + if (recordFrame) return + recordFrame = requestAnimationFrame(() => { + recordFrame = undefined + record() + }) + }).observe(root, { childList: true, subtree: true }) + record() + } + if (document.documentElement ?? document.body) start() + else document.addEventListener("DOMContentLoaded", start, { once: true }) + }) +} + +async function expectCanScrollToStart( + page: Page, + expectedPartIDs: string[], + expectedMessageIDs: string[], + errors: string[], +) { + await pointAtTimeline(page) + const seenParts = new Set() + const seenMessages = new Set() + const samples: TraversalSample[] = [] + let current = await timelineState(page) + let unchangedAtTop = 0 + + for (let attempt = 0; attempt < 600; attempt++) { + collectSeen(current, seenParts, seenMessages) + samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) + expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText) + expectOrderedIDs(expectedPartIDs, current.ids, "mounted part") + expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part") + expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message") + expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message") + + if ( + current.scrollTop <= 1 && + seenParts.size === expectedPartIDs.length && + seenMessages.size === expectedMessageIDs.length + ) { + expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) + return + } + + const before = current + const changed = await scrollTimelineUp(page, current) + current = await timelineState(page) + if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++ + else unchangedAtTop = 0 + if (unchangedAtTop >= 2) break + } + + collectSeen(current, seenParts, seenMessages) + samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) + expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) +} + +async function timelineState(page: Page) { + return page.evaluate( + () => + (window as SmokeWindow).__timelineSmokeState?.() ?? { + ids: [], + visibleIds: [], + messageIds: [], + visibleMessageIds: [], + topVisibleId: undefined, + signature: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + errorToasts: [], + forbiddenText: [], + }, + ) +} + +function timelineScroller(page: Page) { + return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) +} + +async function pointAtTimeline(page: Page) { + const box = await timelineScroller(page).boundingBox() + if (!box) throw new Error("Timeline scroller is not visible") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) +} + +async function scrollTimelineUp(page: Page, before: SmokeState) { + return page.evaluate( + (prev) => + new Promise((resolve) => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row], [data-session-title]"), + ) + if (!scroller) { + resolve(false) + return + } + + scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 })) + scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45))) + + const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + let frames = 0 + let stableFrames = 0 + let last = "" + let changed = false + const check = () => { + const current = read() + if (current !== prev) changed = true + if (current === last) stableFrames++ + else { + stableFrames = 0 + last = current + } + if (changed && stableFrames >= 2) { + resolve(true) + return + } + frames++ + if (frames >= 30) { + resolve(changed) + return + } + requestAnimationFrame(check) + } + requestAnimationFrame(check) + }), + before.signature, + ) +} + +function expectOrderedIDs(expected: string[], actual: string[], label: string) { + expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0) + const actualSet = new Set(actual) + expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id))) +} + +function unique(values: string[]) { + return values.filter((value, index) => values.indexOf(value) === index) +} + +function collectSeen(state: SmokeState, seenParts: Set, seenMessages: Set) { + for (const id of state.ids) seenParts.add(id) + for (const id of state.visibleIds) seenParts.add(id) + for (const id of state.messageIds) seenMessages.add(id) + for (const id of state.visibleMessageIds) seenMessages.add(id) +} + +type TraversalSample = ReturnType + +function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) { + return { + seenParts, + seenMessages, + mounted: state.ids.length, + visible: state.visibleIds.length, + mountedMessages: unique(state.messageIds).length, + visibleMessages: unique(state.visibleMessageIds).length, + top: state.scrollTop, + height: state.scrollHeight, + first: state.ids[0], + last: state.ids.at(-1), + topVisible: state.topVisibleId, + visibleFirst: state.visibleIds[0], + visibleLast: state.visibleIds.at(-1), + } +} + +function sampleSummary(samples: TraversalSample[]) { + return samples + .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1) + .map( + (sample, index) => + `${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`, + ) + .join("\n") +} + +async function waitForTimelineStable(page: Page) { + await page.waitForFunction( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + requestAnimationFrame(() => { + const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + requestAnimationFrame(() => + resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")), + ) + }) + }) + }), + ) +} + +async function expectSessionTimelineReady( + page: Page, + expectedPartIDs: string[], + expectedMessageIDs: string[], + errors: string[], +) { + await waitForTimelineStable(page) + for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0) + const currentState = await timelineState(page) + expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText) + expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part") + expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part") + expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message") + expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message") +} + +function expectCompleteScroll( + state: SmokeState, + expectedPartIDs: string[], + expectedMessageIDs: string[], + seenParts: Set, + seenMessages: Set, + samples: TraversalSample[], +) { + expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1) + expect( + expectedPartIDs.filter((id) => !seenParts.has(id)), + `missing visible timeline parts\n${sampleSummary(samples)}`, + ).toEqual([]) + expect( + expectedMessageIDs.filter((id) => !seenMessages.has(id)), + `missing visible messages\n${sampleSummary(samples)}`, + ).toEqual([]) + expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) + expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) + expect(expectedPartIDs.length).toBe(331) +} + +async function selectHomeProject(page: Page, projectName: string) { + await page.goto("/") + // amicode home: a single seeded project is auto-focused and its row is + // deliberately NOT rendered (HomeProjectList shows rows only when there is + // an actual choice, i.e. >1 projects). The home-cards strip is the stable + // "home ready" signal; the focused project's sessions render alongside it. + await expectAppVisible(page.locator('[data-component="amicode-home-cards"]')) + await expect(page.getByText(new RegExp(projectName, "i")).first()).toBeVisible({ timeout: APP_READY_TIMEOUT }) + await expect(page).toHaveURL(/\/$/) +} + +async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { + await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) + await expectSessionTitle(page, expectedTitle) +} + +async function switchTitlebarSession(page: Page, sessionID: string, title: string) { + const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + +async function expectSessionReady(page: Page) { + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) +} diff --git a/packages/app-bundle/overlay/packages/app/e2e/utils/waits.ts b/packages/app-bundle/overlay/packages/app/e2e/utils/waits.ts new file mode 100644 index 00000000..aaabf19c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/e2e/utils/waits.ts @@ -0,0 +1,11 @@ +import { expect, type Locator, type Page } from "@playwright/test" + +export const APP_READY_TIMEOUT = 45_000 + +export async function expectAppVisible(locator: Locator) { + await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT }) +} + +export async function expectSessionTitle(page: Page, title: string) { + await expectAppVisible(page.getByRole("heading", { name: title })) +} diff --git a/packages/app-bundle/overlay/packages/app/index.html b/packages/app-bundle/overlay/packages/app/index.html new file mode 100644 index 00000000..bb0d1e3b --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/index.html @@ -0,0 +1,28 @@ + + + + + + Amicode + + + + + + + + + + + + + + + +
+ + + diff --git a/packages/app-bundle/overlay/packages/app/playwright.config.ts b/packages/app-bundle/overlay/packages/app/playwright.config.ts new file mode 100644 index 00000000..09707534 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}` +const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" +const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const command = `bun run dev -- --host 0.0.0.0 --port ${port}` +const reuse = !process.env.CI +const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 2 : 0)) || undefined +export default defineConfig({ + testDir: "./e2e", + testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**", + outputDir: "./e2e/test-results", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], + webServer: { + command, + url: baseURL, + reuseExistingServer: reuse, + timeout: 120_000, + env: { + VITE_OPENCODE_SERVER_HOST: serverHost, + VITE_OPENCODE_SERVER_PORT: serverPort, + }, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/app-bundle/overlay/packages/app/public/amico.svg b/packages/app-bundle/overlay/packages/app/public/amico.svg new file mode 120000 index 00000000..f70faa86 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/public/amico.svg @@ -0,0 +1 @@ +../../ui/src/assets/favicon/amico.svg \ No newline at end of file diff --git a/packages/app-bundle/overlay/packages/app/public/assets/Aero-Regular.ttf b/packages/app-bundle/overlay/packages/app/public/assets/Aero-Regular.ttf new file mode 100644 index 00000000..6b7b8e5e Binary files /dev/null and b/packages/app-bundle/overlay/packages/app/public/assets/Aero-Regular.ttf differ diff --git a/packages/app-bundle/overlay/packages/app/public/assets/JuliaMono-Regular.woff2 b/packages/app-bundle/overlay/packages/app/public/assets/JuliaMono-Regular.woff2 new file mode 100644 index 00000000..69819592 Binary files /dev/null and b/packages/app-bundle/overlay/packages/app/public/assets/JuliaMono-Regular.woff2 differ diff --git a/packages/app-bundle/overlay/packages/app/public/assets/RacingSansOne-Regular.woff2 b/packages/app-bundle/overlay/packages/app/public/assets/RacingSansOne-Regular.woff2 new file mode 100644 index 00000000..8fe4e6b3 Binary files /dev/null and b/packages/app-bundle/overlay/packages/app/public/assets/RacingSansOne-Regular.woff2 differ diff --git a/packages/app-bundle/overlay/packages/app/public/oc-theme-preload.js b/packages/app-bundle/overlay/packages/app/public/oc-theme-preload.js new file mode 100644 index 00000000..0ab97420 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/public/oc-theme-preload.js @@ -0,0 +1,51 @@ +;(function () { + var key = "opencode-theme-id" + var themeId = localStorage.getItem(key) || "oc-2" + + if (themeId === "oc-1") { + themeId = "oc-2" + localStorage.setItem(key, themeId) + localStorage.removeItem("opencode-theme-css-light") + localStorage.removeItem("opencode-theme-css-dark") + } + + // amicode: VS Code webview bridge — the extension passes ?colorScheme=light|dark + // (from vscode.window.activeColorTheme). Seed the storage key BEFORE the read + // below so the app boots in the editor's theme (prefers-color-scheme inside a + // webview iframe reports the OS, not VS Code). + try { + var q = new URLSearchParams(location.search).get("colorScheme") + if (q === "light" || q === "dark") localStorage.setItem("opencode-color-scheme", q) + } catch (e) { + /* storage unavailable — keep defaults */ + } + + var scheme = localStorage.getItem("opencode-color-scheme") || "system" + var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches) + var mode = isDark ? "dark" : "light" + + document.documentElement.dataset.theme = themeId + document.documentElement.dataset.colorScheme = mode + document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa" + + // Update theme-color meta tag to match app color scheme + var metas = document.querySelectorAll("meta[name='theme-color']") + if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa") + + if (themeId === "oc-2") return + + var css = localStorage.getItem("opencode-theme-css-" + mode) + if (css) { + var style = document.createElement("style") + style.id = "oc-theme-preload" + style.textContent = + ":root{color-scheme:" + + mode + + ";--text-mix-blend-mode:" + + (isDark ? "plus-lighter" : "multiply") + + ";" + + css + + "}" + document.head.appendChild(style) + } +})() diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/inspector/device-inspector.tsx b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/device-inspector.tsx new file mode 100644 index 00000000..68ef8d65 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/device-inspector.tsx @@ -0,0 +1,123 @@ +import { For, Show, createMemo } from "solid-js" +import type { createInspectorBridge } from "./inspector-bridge" + +type Props = { bridge: ReturnType } + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null +} + +export function DeviceInspector(props: Props) { + const devices = () => Array.from(props.bridge.devices().entries()) + const active = createMemo(() => { + const id = props.bridge.activeDevice() + if (id && props.bridge.devices().has(id)) return { id, data: props.bridge.devices().get(id)! } + const first = devices()[0] + return first ? { id: first[0], data: first[1] } : undefined + }) + + const refresh = (device: string) => { + window.parent.postMessage({ source: "amicode", kind: "device:refresh", device }, "*") + // also try direct parent (when not in deck pane iframe indirection the host still listens) + window.postMessage({ source: "amicode", kind: "device:refresh", device }, "*") + } + + return ( +
+
+
Device Inspector
+ 1}> + + +
+ + +
+
{active()!.id}
+ +
+ + ).driveLines}> + {(dl) => ( +
+
Drive lines
+
+ + {(d) => ( + + {d.id} {d.online ? "●" : "○"} + + )} + +
+
+ )} +
+ + ).qubits}> +
+
Qubits
+
+ + {(q) => {q.qubit}: {q.status}} + +
+
+
+ + ).metrics}> +
+
Metrics
+ }).metrics)}> + {([k, m]) => ( +
+ {k} + + {m.value.toFixed(4)} · {m.status} · {Math.round(m.ageSeconds)}s ago + +
+ )} +
+
+
+ + ).calibrationParams}> +
+
Calibration params
+
+ {JSON.stringify((active()!.data.status as { calibrationParams: unknown }).calibrationParams, null, 2)} +
+
+
+ + 0}> +
+
Recommended actions
+ + {(a) => ( +
+
{a.node} → {a.action} {a.locked ? "🔒" : ""}
+ +
{a.reason}
+
+
+ )} +
+
+
+
+ }> +
No device — configure one in settings.
+ + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/inspector/inspector-bridge.ts b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/inspector-bridge.ts new file mode 100644 index 00000000..8e4b35b7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/inspector-bridge.ts @@ -0,0 +1,122 @@ +import { createSignal, onCleanup } from "solid-js" + +export type RunIteration = { runId: string; iter: number; objective: number; inf_pr: number; inf_du: number } +export type RunPulseMeta = { runId: string; drives: number; knots: number; labels: string[]; bounds: [number, number][]; interp?: string } +export type RunPulse = { runId: string; iter: number; dt: number; values: number[][] } +export type RunCompletion = { runId: string; fidelity: number; iterations: number; status: string } +export type RunBridgeMessage = + | { kind: "run:iteration"; runId: string; iter: number; objective: number; inf_pr: number; inf_du: number } + | { kind: "run:pulse-meta"; runId: string; drives: number; knots: number; labels: string[]; bounds: [number, number][]; interp?: string } + | { kind: "run:pulse"; runId: string; iter: number; dt: number; values: number[][] } + | { kind: "run:completion"; runId: string; fidelity: number; iterations: number; status: string } + | { kind: "run:activate"; runId: string } + | { kind: "run:timing"; runId: string; elapsed: number } + | { kind: "run:label"; runId: string; label: string } + +export type DeviceBridgeMessage = + | { kind: "device:status"; device: string; status: unknown } + | { kind: "device:actions"; device: string; actions: unknown[] } + | { kind: "device:activate"; device: string } + +export type InspectorMessage = RunBridgeMessage | DeviceBridgeMessage + +type RunState = { + label?: string + iterations: RunIteration[] + pulseMeta?: RunPulseMeta + pulses: RunPulse[] + completion?: RunCompletion + timing?: number +} + +export function createInspectorBridge() { + const [runs, setRuns] = createSignal>(new Map()) + const [activeRunId, setActiveRunId] = createSignal(undefined) + const [devices, setDevices] = createSignal>(new Map()) + const [activeDevice, setActiveDevice] = createSignal(undefined) + + const onMessage = (e: MessageEvent) => { + const d = e.data as InspectorMessage & { source?: string } + if (!d || d.source !== "amicode") return + switch (d.kind) { + case "run:iteration": { + setRuns((m) => { + const n = new Map(m) + const s = n.get(d.runId) ?? { iterations: [], pulses: [] } + n.set(d.runId, { ...s, iterations: [...s.iterations, { runId: d.runId, iter: d.iter, objective: d.objective, inf_pr: d.inf_pr, inf_du: d.inf_du }] }) + return n + }) + if (!activeRunId()) setActiveRunId(d.runId) + break + } + case "run:pulse-meta": + setRuns((m) => { + const n = new Map(m) + const s = n.get(d.runId) ?? { iterations: [], pulses: [] } + n.set(d.runId, { ...s, pulseMeta: d }) + return n + }) + break + case "run:pulse": + setRuns((m) => { + const n = new Map(m) + const s = n.get(d.runId) ?? { iterations: [], pulses: [] } + n.set(d.runId, { ...s, pulses: [...s.pulses.slice(-200), d] }) + return n + }) + break + case "run:completion": + setRuns((m) => { + const n = new Map(m) + const s = n.get(d.runId) ?? { iterations: [], pulses: [] } + n.set(d.runId, { ...s, completion: d }) + return n + }) + break + case "run:activate": + setActiveRunId(d.runId) + break + case "run:label": + setRuns((m) => { + const n = new Map(m) + const s = n.get(d.runId) ?? { iterations: [], pulses: [] } + n.set(d.runId, { ...s, label: d.label }) + return n + }) + break + case "run:timing": + setRuns((m) => { + const n = new Map(m) + const s = n.get(d.runId) ?? { iterations: [], pulses: [] } + n.set(d.runId, { ...s, timing: d.elapsed }) + return n + }) + break + case "device:status": + setDevices((m) => { + const n = new Map(m) + const cur = n.get(d.device) ?? { status: undefined, actions: [] } + n.set(d.device, { ...cur, status: d.status }) + return n + }) + if (!activeDevice()) setActiveDevice(d.device) + break + case "device:actions": + setDevices((m) => { + const n = new Map(m) + const cur = n.get(d.device) ?? { status: undefined, actions: [] } + n.set(d.device, { ...cur, actions: d.actions }) + return n + }) + break + case "device:activate": + setActiveDevice(d.device) + break + } + } + + window.addEventListener("message", onMessage) + onCleanup(() => window.removeEventListener("message", onMessage)) + + return { runs, activeRunId, setActiveRunId, devices, activeDevice, setActiveDevice } +} diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/inspector/inspector-context.tsx b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/inspector-context.tsx new file mode 100644 index 00000000..b50570e0 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/inspector-context.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, type ParentProps } from "solid-js" +import { createInspectorBridge } from "./inspector-bridge" + +type InspectorBridge = ReturnType + +const InspectorContext = createContext() + +export function InspectorProvider(props: ParentProps) { + const bridge = createInspectorBridge() + return {props.children} +} + +export function useInspectorBridge(): InspectorBridge { + const ctx = useContext(InspectorContext) + if (!ctx) throw new Error("useInspectorBridge must be used within an InspectorProvider") + return ctx +} diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/inspector/run-inspector.tsx b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/run-inspector.tsx new file mode 100644 index 00000000..ea0f42a6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/inspector/run-inspector.tsx @@ -0,0 +1,124 @@ +import { For, Show, createMemo } from "solid-js" +import type { createInspectorBridge } from "./inspector-bridge" + +type Props = { bridge: ReturnType } + +// Minimal pulse sparkline — renders correctly at narrow column widths and responds +// to resize via viewBox (no fixed pixel width). Each drive is a polyline. +function PulseChart(props: { values?: number[][]; bounds?: [number, number][] }) { + const paths = createMemo(() => { + const v = props.values + if (!v || v.length === 0 || v[0].length === 0) return [] + return v.map((drive, idx) => { + const b = props.bounds?.[idx] + const lo = b?.[0] ?? Math.min(...drive) + const hi = b?.[1] ?? Math.max(...drive) + const range = hi - lo || 1 + const pts = drive.map((val, i) => { + const x = (i / (drive.length - 1)) * 100 + const y = 50 - ((val - lo) / range) * 40 - 5 + return `${x},${y}` + }) + return { d: `M ${pts.join(" L ")}`, color: idx % 2 === 0 ? "var(--amico-accent, #eab308)" : "var(--amico-accent-2, #38bdf8)" } + }) + }) + return ( + + + + + {(p) => } + + ) +} + +export function RunInspector(props: Props) { + const runs = () => Array.from(props.bridge.runs().entries()) + const active = createMemo(() => { + const id = props.bridge.activeRunId() + if (id && props.bridge.runs().has(id)) return { id, state: props.bridge.runs().get(id)! } + const first = runs()[0] + return first ? { id: first[0], state: first[1] } : undefined + }) + const latestIter = createMemo(() => active()?.state.iterations.at(-1)) + const latestPulse = createMemo(() => active()?.state.pulses.at(-1)) + + return ( +
+ {/* Run selector — only shown when multiple runs exist */} + 1}> +
+ +
+
+ + + {/* Pulse metadata */} + + {(meta) => ( +
+ {meta().drives} control channel{meta().drives > 1 ? "s" : ""} · {meta().knots} timesteps + 0}> + · {meta().labels.join(", ")} + +
+ )} +
+ + {/* Pulse chart — always visible once a run exists */} + + + {/* Metrics table — horizontally scrollable at narrow widths */} +
+ + + + + + + + + + + + + + + + + +
IterObjectivePrimalDual
{latestIter()?.iter ?? "—"}{latestIter() ? latestIter()!.objective.toExponential(2) : "—"}{latestIter() ? latestIter()!.inf_pr.toExponential(1) : "—"}{latestIter() ? latestIter()!.inf_du.toExponential(1) : "—"}
+
+ + {/* Completion card */} + + {(c) => ( +
+
Result
+
F = {c().fidelity.toFixed(5)}
+
+ {c().status} · {c().iterations} iterations + · {active()!.state.timing!.toFixed(1)}s +
+
+ )} +
+ + {/* Running indicator */} + +
solving · iteration {latestIter()!.iter}
+
+
+ }> +
No solve yet — launch one from the chat.
+ + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx new file mode 100644 index 00000000..fc84096a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/app.tsx @@ -0,0 +1,772 @@ +import "@/index.css" +import * as Sentry from "@sentry/solid" +import { requestComputeConnect } from "@/components/amicode-defaults-capsule" +import { I18nProvider } from "@opencode-ai/ui/context" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { FileComponentProvider } from "@opencode-ai/ui/context/file" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { File } from "@opencode-ai/session-ui/file" +import { Font } from "@opencode-ai/ui/font" +import { Splash } from "@opencode-ai/ui/logo" +import { ThemeProvider, useTheme } from "@opencode-ai/ui/theme/context" +import { MetaProvider } from "@solidjs/meta" +import { + type BaseRouterProps, + Navigate, + Route, + Router, + useLocation, + useNavigate, + useParams, + useSearchParams, +} from "@solidjs/router" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { Effect } from "effect" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { + type Component, + createEffect, + createMemo, + createRenderEffect, + createResource, + createSignal, + ErrorBoundary, + For, + type JSX, + lazy, + onCleanup, + type ParentProps, + Show, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import { makeEventListener } from "@solid-primitives/event-listener" +import { CommandProvider, useCommand, type CommandOption } from "@/context/command" +import { CommentsProvider } from "@/context/comments" +import { FileProvider } from "@/context/file" +import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSyncProvider, useServerSync } from "@/context/server-sync" +import { GlobalProvider, useGlobal } from "@/context/global" +import { HighlightsProvider } from "@/context/highlights" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" +import { LayoutProvider } from "@/context/layout" +import { SplitProvider } from "@/context/split" +import { WorkbenchProvider } from "@/context/workbench" +import { ModelsProvider } from "@/context/models" +import { NotificationProvider } from "@/context/notification" +import { PermissionProvider } from "@/context/permission" +import { usePlatform } from "@/context/platform" +import { setPendingAutoSend } from "@/pages/new-session/new-session-draft-controller" +import { PromptProvider } from "@/context/prompt" +import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" +import { SettingsProvider, useSettings } from "@/context/settings" +import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" +import { SDKProvider, useSDK } from "@/context/sdk" +import { WslServersProvider } from "@/wsl/context" +import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" +import LegacyLayout from "@/pages/layout" +import NewLayout from "@/pages/layout-new" +import { ErrorPage } from "./pages/error" +import { useCheckServerHealth } from "./utils/server-health" +import { AmicodeSplash } from "@opencode-ai/ui/amicode-splash" +import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" +import { createSessionLineage } from "@/pages/session/session-lineage" +import { bugDockController } from "@/pages/session/composer/bug-dock-controller" +import { postBugReportPoke } from "@/utils/amicode-bug-report" + +import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" +import { NewHome } from "@/pages/home" +import { LegacyHome } from "@/pages/home/legacy-home" +import { AmicodeFileRefBridge } from "@/components/amicode-file-ref-bridge" +import { DevToolsReopenBridge } from "@/components/settings-dialog" + +const NewSession = lazy(() => import("@/pages/new-session")) + +const SessionRoute = () => { + const settings = useSettings() + const params = useParams() + const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() + const sdk = useSDK() + const server = useServer() + const tabs = useTabs() + + if (params.id && settings.general.newLayoutDesigns()) { + const sessionID = params.id + return ( + + {(_) => { + const persisted = tabs.store.filter((item) => item.type === "session") + return + }} + + ) + } + + // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) + // is replaced by a draft at /new-session?draftId=… + createEffect(() => { + if (!settings.general.newLayoutDesigns()) return + if (params.id || search.draftId) return + if (!tabs.ready() || !sdk().directory) return + tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) + }) + + return ( + + + + ) +} + +function TargetServerRoute(props: ParentProps) { + const params = useParams<{ serverKey: string; id: string }>() + const global = useGlobal() + const conn = createMemo(() => { + const key = requireServerKey(params.serverKey) + return global.servers.list().find((item) => ServerConnection.key(item) === key) + }) + + return ( + // Owns the server-identity remount. Session changes must NOT remount this + // subtree (SessionRouteErrorBoundary resets and createSessionLineage + // re-resolves reactively instead); both rely on this key for server changes. + + + {props.children} + + + ) +} + +const TargetSessionRoute = () => ( + + + +) + +function LegacyTargetSessionRoute() { + const params = useParams<{ serverKey: string; id: string }>() + return ( + + + + + + ) +} + +function LegacyTargetSessionRedirect() { + const params = useParams<{ id: string }>() + const navigate = useNavigate() + const sync = useServerSync() + const current = createSessionLineage( + () => params.id, + () => sync().session.lineage, + ) + + createEffect(() => { + const directory = current()?.session.directory + if (!directory) return + navigate(legacySessionHref(directory, params.id), { replace: true }) + }) + + return null +} + +// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected +// server via ServerKey, then provide the server-scoped shell for that server. +function SelectedServerProviders(props: ParentProps) { + return ( + + + + + {props.children} + + + + ) +} + +function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { + return ( + + {props.children} + + ) +} + +function DraftRoute() { + const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() + const tabs = useTabs() + return ( + + tab.type === "draft" && tab.draftID === search.draftId)} + keyed + fallback={} + > + {(draft) => ( + } + > + + + )} + + + ) +} + +function ResolvedDraftRoute(props: { draft: DraftTab }) { + const global = useGlobal() + const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) + const directory = () => props.draft.directory + const serverKey = () => props.draft.server + + return ( + + + + + + + + + + + + + + + + ) +} + +function UiI18nBridge(props: ParentProps) { + const language = useLanguage() + return {props.children} +} + +function LayoutCompatibility(props: ParentProps) { + const global = useGlobal() + const navigate = useNavigate() + const server = useServer() + const settings = useSettings() + + createEffect(() => { + if (settings.general.newLayoutDesigns()) return + const current = server.current + if (!current) return + const protocol = global.ensureServerCtx(current).sdk.protocolKind() + if (protocol !== "v2") return + const next = global.servers.list().find((s) => { + if (ServerConnection.key(s) === ServerConnection.key(current)) return false + return global.ensureServerCtx(s).sdk.protocolKind() !== "v2" + }) + if (!next) return + navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(next))) + }) + + return <>{props.children} +} + +declare global { + interface Window { + __OPENCODE__?: { + deepLinks?: string[] + } + api?: { + setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise + exportDebugLogs?: () => Promise + } + } +} + +function QueryProvider(props: ParentProps) { + const client = new QueryClient({ + defaultOptions: { + queries: { + refetchOnReconnect: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + }, + }) + return {props.children} +} + +function BodyDesignClass() { + const settings = useSettings() + + createRenderEffect(() => { + if (typeof document === "undefined") return + + const enabled = settings.general.newLayoutDesigns() + document.body.toggleAttribute("data-new-layout", enabled) + document.body.classList.toggle("text-12-regular", !enabled) + document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) + document.body.classList.toggle("text-[13px]", enabled) + document.body.classList.toggle("font-[440]", enabled) + }) + + return null +} + +// Server-agnostic providers shared across every route. These live in the shared +// shell (router root) so they stay mounted regardless of the active server/route. +function SharedProviders(props: ParentProps) { + return ( + <> + + + + + {/* amicode(split): the in-app pane state wraps the shell — the + titlebar (drag sources) and the layout (drop zones) both + consume it. amicode(workbench S2): the parent's tab mirror + sits above it — drops resolve against the mirror. */} + + {props.children} + + + + + ) +} + +function DesktopCommands() { + const command = useCommand() + const language = useLanguage() + const platform = usePlatform() + + command.register("desktop", () => { + const commands: CommandOption[] = [] + if (platform.platform === "desktop" && platform.exportDebugLogs) { + commands.push({ + id: "logs.export", + title: "Export logs", + category: language.t("command.category.settings"), + onSelect: () => { + void platform.exportDebugLogs?.() + }, + }) + } + return commands + }) + + return null +} + +// Server-scoped providers shared by the legacy shell and the top-level new shell. +type ServerScopedShellProps = ParentProps<{ + directory?: () => string | undefined + serverScoped?: JSX.Element +}> + +function ServerScopedProviders(props: ServerScopedShellProps) { + return ( + + {props.serverScoped} + {props.children} + + ) +} + +function LegacyServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + +function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { + return ( + + + {props.children} + + + ) +} + +// The draft page only renders the prompt composer, so it drops TerminalProvider. +// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. +function DraftProviders(props: ParentProps) { + return ( + + + {props.children} + + + ) +} + +/** amicode: live theme bridge for the VS Code webview host. The extension + * forwards editor theme changes as window messages (outer relay → iframe); + * route them through the existing setColorScheme so everything re-themes. */ +function AmicodeThemeBridge() { + const theme = useTheme() + const onMsg = (e: MessageEvent) => { + const d = e.data as { source?: string; kind?: string; colorScheme?: string } | undefined + if (d?.source !== "amicode") return + // amicode#200 AC6: the Connect Cloud palette command deep-links into the + // defaults capsule's compute-connect flow (consumed when home is showing). + if (d.kind === "open-compute-connect") { + requestComputeConnect() + return + } + // amicode/opencode#117: bug-report dock open/close down-messages. Handled + // at app level (not in the dock) so an open can't be missed between + // pages; the controller self-gates on the boot param + kind. + if (d.kind === "open-bug-report" || d.kind === "close-bug-report") { + bugDockController.handleBridgeMessage(d) + return + } if (d.kind !== "theme") return + if (d.colorScheme === "light" || d.colorScheme === "dark") theme.setColorScheme(d.colorScheme) + } + window.addEventListener("message", onMsg) + onCleanup(() => window.removeEventListener("message", onMsg)) + // ⌘⇧P / Ctrl+Shift+P: when embedded in the amicode webview (we have a + // parent), the EDITOR's Command Palette wins over the app's own palette — + // capture-phase so the in-app binding never sees it; forwarded over the + // existing allowlisted command bridge. + const onKey = (e: KeyboardEvent) => { + if (window.parent === window) return + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "p" || e.key === "P")) { + e.preventDefault() + e.stopPropagation() + window.parent.postMessage({ source: "amicode", kind: "command", command: "workbench.action.showCommands" }, "*") + } + } + window.addEventListener("keydown", onKey, { capture: true }) + onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true })) + // The boot poke — the dock contract's pull half (QA follow-up, amicode#249 + // preview): posted once per app-frame boot when the flag is on; the + // extension re-posts open-bug-report if a bug session is live, so a lost + // one-shot open (cold-boot race, webview reload) self-heals. + postBugReportPoke() + return null +} + +/** amicode#363: bridge for the extension to open a new draft session with a + * pre-filled prompt. Must live inside TabsProvider + ServerProvider so it has + * access to useTabs().newDraft. */ +function AmicodeNavigateBridge() { + const tabs = useTabs() + const server = useServer() + let pending = false + + // Signal to the extension host that the app is mounted and ready to + // receive navigate messages. The extension uses this to dismiss the + // onboarding splash and post the greeting (instead of blind timeouts). + window.parent.postMessage({ source: "amicode", kind: "app-ready" }, "*") + + const onMsg = async (e: MessageEvent) => { + const d = e.data as { source?: string; kind?: string; path?: string } | undefined + if (d?.source !== "amicode" || d.kind !== "navigate" || !d.path) return + if (pending) return + pending = true + try { + const url = new URL(d.path, window.location.origin) + if (url.pathname === "/new-session") { + const prompt = url.searchParams.get("prompt") || undefined + const autoSend = url.searchParams.get("autoSend") === "1" + await tabs.newDraft({ server: server.key, directory: server.projects.list()[0]?.worktree ?? "" }, prompt) + if (autoSend) setPendingAutoSend(true) + } else { + // Navigate to an existing session by path (e.g. /session/:id) + const sessionMatch = url.pathname.match(/^\/session\/([^/?]+)/) + if (sessionMatch) { + const sessionId = sessionMatch[1] + tabs.openPath(`/${server.key}/session/${sessionId}`, { activate: true }) + } + } + } catch { /* malformed path — ignore */ } + finally { setTimeout(() => { pending = false }, 2000) } + } + window.addEventListener("message", onMsg) + onCleanup(() => window.removeEventListener("message", onMsg)) + return null +} + +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { + return ( + + + { + void window.api?.setTitlebar?.({ mode, scheme }) + }} + > + + + + { + Sentry.captureException(error) + return + }} + > + + + + + + {props.children} + + + + + + + + + + ) +} + +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { + const server = useServer() + const checkServerHealth = useCheckServerHealth() + + const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") + + // performs repeated health check with a grace period for + // non-http connections, otherwise fails instantly + const [startupHealthCheck, healthCheckActions] = createResource(() => + props.disableHealthCheck + ? true + : Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current + + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), + ) + const checking = createMemo( + () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), + ) + const [startup] = createResource(async () => { + if (!props.startup) return true + await props.startup.catch((error) => { + console.error("[startup] startup gate failed", error) + }) + return true + }) + const startupChecking = createMemo( + () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), + ) + const loading = createMemo(() => checking() || startupChecking()) + + return ( + <> + + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + + + {/* amicode: brand splash at the loading surface */} +
+ +
+
+ + ) +} + +function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { + const language = useLanguage() + const server = useServer() + const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) + const name = createMemo(() => server.name || server.key) + const serverToken = "\u0000server\u0000" + const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) + + const timer = setInterval(() => props.onRetry?.(), 1000) + onCleanup(() => clearInterval(timer)) + + return ( +
+
+ +

+ {unreachable()[0]} + {name()} + {unreachable()[1]} +

+

{language.t("app.server.retrying")}

+
+ 0}> +
+ {language.t("app.server.otherServers")} +
+ + {(conn) => { + const key = ServerConnection.key(conn) + return ( + + ) + }} + +
+
+
+
+ ) +} + +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + +export function AppInterface(props: { + children?: JSX.Element + defaultServer: ServerConnection.Key + canonicalLocalServer?: ServerConnection.Key + servers?: Array + router?: Component + disableHealthCheck?: boolean + startup?: Promise + serverScoped?: JSX.Element +}) { + // The visual new layout lives in the router root so it remains mounted across + // route changes. Draft and session routes override only their server-bound data + // providers beneath it. + const ServerShell = (shellProps: ParentProps) => ( + + + {props.children} + {shellProps.children} + + + ) + + return ( + + + + + + ( + + + + + + + {routerProps.children} + + + + + + )} + > + + + + + + + + ) +} + +function Routes(props: { serverScoped?: JSX.Element }) { + const settings = useSettings() + + return ( + <> + ( + {routeProps.children} + )} + > + + { + <> + + + + } + + + } /> + + + + + + + + + + + ) +} + +function NewLayoutLegacySessionRedirect() { + const server = useServer() + const tabs = useTabs() + const params = useParams<{ id: string }>() + + return ( + + item.type === "session"), + params.id, + server.key, + ), + params.id, + )} + /> + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model-value.ts b/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model-value.ts new file mode 100644 index 00000000..b660eceb --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model-value.ts @@ -0,0 +1,17 @@ +// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model +// control. Split out of amicode-default-model.tsx so they're unit-testable +// without evaluating the SolidJS component (which touches client-only context). + +/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */ +export function formatModelValue(key: { providerID: string; modelID: string }): string { + return `${key.providerID}/${key.modelID}` +} + +/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the + * FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…) + * round-trips. */ +export function parseModelValue(value: string): { providerID: string; modelID: string } | null { + const slash = value.indexOf("/") + if (slash <= 0 || slash === value.length - 1) return null + return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model.test.ts b/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model.test.ts new file mode 100644 index 00000000..81f1cc7a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { formatModelValue, parseModelValue } from "./amicode-default-model-value" + +describe("amicode default-model value round-trip", () => { + test("format then parse is identity, incl. model ids with dots/dashes", () => { + const key = { providerID: "anthropic", modelID: "claude-sonnet-5" } + expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5") + expect(parseModelValue(formatModelValue(key))).toEqual(key) + expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({ + providerID: "opencode", + modelID: "deepseek-v4-flash-free", + }) + }) + + test("splits on the FIRST slash so a model id may itself contain slashes", () => { + expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({ + providerID: "openrouter", + modelID: "meta-llama/llama-3.3", + }) + }) + + test("rejects malformed / empty values (→ null, no selection)", () => { + expect(parseModelValue("")).toBeNull() + expect(parseModelValue("noslash")).toBeNull() + expect(parseModelValue("/leading")).toBeNull() + expect(parseModelValue("trailing/")).toBeNull() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model.tsx b/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model.tsx new file mode 100644 index 00000000..2e7cd6c3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-default-model.tsx @@ -0,0 +1,106 @@ +import { For, Show, createMemo } from "solid-js" +import { useModels } from "@/context/models" +import { formatModelValue, parseModelValue } from "./amicode-default-model-value" + +// Dashboard "Default model" control. Drives opencode's GLOBAL model store +// (useModels — mounted app-wide, persisted via Persist.global), which is what +// new chats inherit as their starting model (see local.tsx recentModel()), so +// a change takes effect immediately with no server restart. It ALSO mirrors the +// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent +// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync. +// The in-chat picker still overrides per session. + +/** Post the chosen model to the extension host so it updates amicode.defaultModel. + * No-op when unframed (plain web/desktop): there's no parent shell to relay it. */ +function syncDefaultModelPin(model: string, win: Window = window): void { + if (win.parent === win) return + win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*") +} + +export function AmicodeDefaultModel() { + const models = useModels() + + const available = createMemo(() => models.list()) + // opencode's default visibility hides dated, non-"latest" models — which can + // hide EVERY model and leave the control empty. Prefer the visible set, but + // fall back to all available models so the picker is never blank when models + // exist. (Guarded below on available(), so it only hides with zero models.) + const selectable = createMemo(() => { + const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id })) + return vis.length > 0 ? vis : available() + }) + + const groups = createMemo(() => { + const byProvider = new Map() + for (const m of selectable()) { + const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] } + group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name }) + byProvider.set(m.provider.id, group) + } + return [...byProvider.values()] + }) + + // Bound to the DURABLE pin (models.pin), not the recents head — chats using + // other models must not move the dashboard's declared default. Empty = Auto + // (no pin; new chats fall back to recents/provider default). + const current = createMemo(() => { + const pin = models.pin.get() + return pin ? formatModelValue(pin) : "" + }) + + const choose = (value: string) => { + if (value === "") { + models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting + syncDefaultModelPin("") + return + } + const key = parseModelValue(value) + if (!key) return + models.pin.set(key) + syncDefaultModelPin(value) + } + + return ( + 0}> + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app-bundle/overlay/packages/app/src/components/amicode-defaults-capsule.tsx new file mode 100644 index 00000000..89414530 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-defaults-capsule.tsx @@ -0,0 +1,475 @@ +import { Show, batch, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" +import { useModels } from "@/context/models" +import { AmicodeDefaultModel } from "./amicode-default-model" +import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown" +import { + hpAfterConnect, + hpClickAction, + loadSolverMode, + modeAfterDisconnect, + saveSolverMode, + solverConnectionDot, + type SolverMode, +} from "@opencode-ai/ui/amicode-solver-toggle" +import { + ConnectionCard, + type ConnectionActionView, + type ConnectionsTabLabels, + type ConnectionView, + type CredentialSubmitPayload, +} from "@opencode-ai/ui/amicode-connections-tab" + +// AMICODE: the chrome strip's DEFAULTS CAPSULE (nav redesign, Kate 2026-07-15). +// One compact control carries both session defaults — model and solver — so the +// bar holds one row at every panel width. The face reads the current pair +// ("Auto · Piccolo"); the popover holds the full model select (the existing +// AmicodeDefaultModel, unchanged: global pin + VS Code-setting mirror) and a +// solver radio pair sharing solver-toggle.tsx's persistence. When the +// high-performance solver is on, the face wears the accent border — the PRO +// funnel stays visible at every width instead of being the first casualty +// of a narrow column. +// +// amicode#200: the solver toggle owns the Company Compute connection. The HP +// radio wears a status dot (green connected / amber attention / gray no key), +// and clicking it while unconnected expands the SAME connection card the +// status popover used to host — connect here, HP flips on when the credential +// lands connected. Connected + already-HP clicks open the card in its +// management state (identity, validated-at, revalidate, disconnect). +// Disconnect always drops the mode back to Piccolo: HP is cloud-only. + +/** The Company Compute slice of createAmicodeConnectionsState, passed down by + * the host chrome. Optional so bare mounts (storybook) keep legacy behavior. */ +export type AmicodeComputeControl = { + view: Accessor + labels: Accessor + actionError: Accessor + onSubmit: (payload: CredentialSubmitPayload) => Promise + onDisconnect: (id: string) => void + onRevalidate: (id: string) => void + refetch: () => void +} + +// The status dot lives on the connection card's STATUS line (Kate: the dot +// accompanies "not connected…", never the solver name — same idiom as the +// Connections tab, where the dot leads the card's first line). The header +// row signals state non-visually via aria-label/title and data-dot. + +// amicode#200 AC6: the Connect Cloud palette command deep-links here. The +// extension posts an "open-compute-connect" envelope through the webview +// bridge; the app-shell listener calls requestComputeConnect() and the next +// mounted capsule opens straight into the connect flow. Timestamped so a +// request fired while another page was showing can't pop the popover minutes +// later (15s freshness window). +const [connectRequestAt, setConnectRequestAt] = createSignal(0) +export function requestComputeConnect() { + setConnectRequestAt(Date.now()) +} +const CONNECT_REQUEST_TTL_MS = 15_000 + +export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl }) { + const models = useModels() + const [open, setOpen] = createSignal(false) + const [mode, setMode] = createSignal(loadSolverMode()) + const [computeOpen, setComputeOpen] = createSignal(false) + const pick = (m: SolverMode) => { + setMode(m) + saveSolverMode(m) + } + + const dot = createMemo(() => solverConnectionDot(props.compute?.view())) + const onHpClick = () => { + if (!props.compute) { + pick("hp") // legacy seam: unwired hosts keep the old behavior + return + } + if (hpClickAction(dot()) === "activate") { + pick("hp") + return + } + // not connected — reveal the connect card inline; freshen the view + setComputeOpen(true) + props.compute.refetch() + } + // details (gear): the persistent way back to the connection card once a + // credential exists — connect success must never strand the user with no + // visible route to identity / revalidate / disconnect (Kate, 2026-07-22) + const onDetailsClick = () => { + if (computeOpen()) { + setComputeOpen(false) + return + } + setComputeOpen(true) + props.compute?.refetch() + } + const submitCredential = async (payload: CredentialSubmitPayload) => { + const result = await props.compute!.onSubmit(payload) + if (hpAfterConnect(result)) pick("hp") + return result + } + const disconnectCompute = (id: string) => { + props.compute!.onDisconnect(id) + pick(modeAfterDisconnect()) + } + + const modelName = createMemo(() => { + const pin = models.pin.get() + if (!pin) return "Auto" + const m = models.list().find((x) => x.provider.id === pin.providerID && x.id === pin.modelID) + return m?.name ?? "Auto" + }) + const hp = () => mode() === "hp" + + let root: HTMLDivElement | undefined + let hpRow: HTMLDivElement | undefined + const close = () => { + setOpen(false) + setComputeOpen(false) + clearChromeDropdown("defaults") + } + // amicode#203: one chrome dropdown at a time (see chrome-dropdown.ts). + createEffect(() => { + if (chromeDropdownOpenId() !== "defaults" && open()) { + setOpen(false) + setComputeOpen(false) + } + }) + const onDocPointer = (e: PointerEvent) => { + if (root && !root.contains(e.target as Node)) { + close() + return + } + // inside the popover but outside the HP accordion → collapse the card + // (Kate: the disclosure dismisses like any other; the kebab reopens it) + if (computeOpen() && hpRow && !hpRow.contains(e.target as Node)) setComputeOpen(false) + } + const onDocKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close() + } + const openPopover = () => { + if (open()) return + // batch: announce + open flag must land atomically (press-twice bug). + batch(() => { + announceChromeDropdown("defaults") + setOpen(true) + }) + document.addEventListener("pointerdown", onDocPointer, true) + document.addEventListener("keydown", onDocKey, true) + } + const toggle = () => { + if (open()) { + close() + teardown() + } else { + openPopover() + } + } + + // amicode#200 AC6: consume a pending Connect Cloud deep-link — open the + // popover directly into the compute connect flow. + createEffect(() => { + const at = connectRequestAt() + if (!at || !props.compute) return + setConnectRequestAt(0) + if (Date.now() - at > CONNECT_REQUEST_TTL_MS) return + openPopover() + setComputeOpen(true) + props.compute.refetch() + }) + const teardown = () => { + document.removeEventListener("pointerdown", onDocPointer, true) + document.removeEventListener("keydown", onDocKey, true) + } + onCleanup(teardown) + + const radio = (active: boolean): Record => ({ + display: "flex", + "align-items": "center", + gap: "8px", + width: "100%", + padding: "6px 10px", + "font-size": "12px", + "font-weight": active ? "650" : "400", + "text-align": "left", + border: active ? "1px solid var(--accent-edge)" : "1px solid var(--v2-border-border-base)", + "border-radius": "var(--radius-md)", + cursor: "pointer", + background: active ? "var(--accent-fill-soft)" : "transparent", + color: active ? "var(--v2-text-text-base)" : "var(--v2-text-text-muted)", + }) + + return ( +
+ + +
+ +
+
+ + Solver + + + {/* One bordered control (Kate, 2026-07-22): a single accordion — + header row (select + kebab click zones) and the API-key card + expand/collapse INSIDE the same border, split by a hairline. */} +
+
+ + + + +
+ + {/* aria-live: the card's state copy (validating → connected / + error) announces to screen readers; focus lands here on open + so keyboard users reach the form without tabbing blind. + The dot on the radio is color-only — its aria-label carries + the state in text (WCAG color-not-only). */} +
setTimeout(() => el.focus(), 0)} + style={{ + padding: "0 2px 4px", + outline: "none", + }} + > + } + > + {(conn) => ( + + )} + +
+
+
+
+
+ +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx b/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx new file mode 100644 index 00000000..eadf0cbb --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx @@ -0,0 +1,33 @@ +// AMICODE: registers the chat file-reference resolver with session-ui's +// markdown renderer. Rides the same per-call server pick as the vault browser +// (focused server, else first healthy, else first), so a server switch is +// picked up by the very next resolution; resolutions never block render. +// Mounted under SelectedServerProviders (needs the server + global contexts); +// with the bridge unmounted no resolver is registered and pills stay pills. +import { onCleanup, onMount } from "solid-js" +import { registerFileRefResolver } from "@opencode-ai/session-ui/markdown-file-refs" +import { ServerConnection, useServer } from "@/context/server" +import { useGlobal } from "@/context/global" +import { amicodeGet } from "@/utils/amicode-fetch" +import { pickVaultServer } from "@/components/vault-browser-model" + +export function AmicodeFileRefBridge() { + const server = useServer() + const global = useGlobal() + onMount(() => { + registerFileRefResolver(async (text) => { + const conn = pickVaultServer({ + current: server.current, + list: server.list, + healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true, + }) + if (!conn) return null + const raw = (await amicodeGet(conn, `/amicode/resolve-file?path=${encodeURIComponent(text)}`)) as + | { ok?: boolean; found?: boolean; path?: unknown } + | undefined + return raw?.ok === true && raw.found === true && typeof raw.path === "string" ? raw.path : null + }) + }) + onCleanup(() => registerFileRefResolver(undefined)) + return null +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx b/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx new file mode 100644 index 00000000..dcb7bba1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/connection-banner.tsx @@ -0,0 +1,77 @@ +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" + +// Amicode webview: a dead server (e.g. an opencode:build restart) used to read +// as an endless "thinking" wave — the event loop retries silently every 250ms. +// Surface the drop and the recovery. The fresh stream's server.connected makes +// server-sync refetch everything, so recovery needs no reload in most cases. +export function ConnectionBanner() { + const sdk = useServerSDK() + const language = useLanguage() + const [connectedOnce, setConnectedOnce] = createSignal(false) + const [wasDown, setWasDown] = createSignal(false) + const [flash, setFlash] = createSignal(false) + let flashTimer: ReturnType | undefined + onCleanup(() => { + if (flashTimer !== undefined) clearTimeout(flashTimer) + }) + + createEffect(() => { + const status = sdk().event.status() + // Always clear wasDown when we're connected, even on first connect + if (status === "connected") { + if (wasDown()) { + setWasDown(false) + setFlash(true) + if (flashTimer !== undefined) clearTimeout(flashTimer) + flashTimer = setTimeout(() => { + setFlash(false) + flashTimer = undefined + }, 3000) + } + if (!connectedOnce()) { + setConnectedOnce(true) + } + return + } + if (status === "disconnected") { + // Only report drops after a first successful connect — boot renders + // "disconnected" transiently while the stream is still being set up. + if (connectedOnce()) setWasDown(true) + if (flashTimer !== undefined) { + clearTimeout(flashTimer) + flashTimer = undefined + } + setFlash(false) + } + }) + + const down = () => wasDown() && sdk().event.status() === "disconnected" + + return ( + +
+ {down() ? language.t("app.server.connectionLost") : language.t("app.server.reconnected")} +
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx new file mode 100644 index 00000000..e75364a8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/dialog-new-project.tsx @@ -0,0 +1,102 @@ +// amicode#203: New-project creation dialog. Name → slug preview; the server +// owns the location default (~/AmicodeProjects) and the mkdir + best-effort +// git init (POST /amicode/project). Inline, recoverable errors for collision / +// unwritable; a non-blocking git notice is surfaced by the caller (onCreated). +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" +import { createMemo, createSignal, Show } from "solid-js" +import { ServerConnection } from "@/context/server" +import { amicodePost } from "@/utils/amicode-fetch" +import { projectNameToSlug } from "@/pages/home-projects" + +type CreateResult = + | { ok: true; path: string; slug: string; gitInitialized: boolean } + | { ok: false; error: string; message: string } + +export function DialogNewProject(props: { + server: ServerConnection.Any + onCreated: (result: { path: string; gitInitialized: boolean }) => void + onOpenExisting: () => void +}) { + const dialog = useDialog() + const [name, setName] = createSignal("") + const [error, setError] = createSignal() + const slug = createMemo(() => projectNameToSlug(name())) + + const createMutation = useMutation(() => ({ + mutationFn: async (): Promise => { + setError(undefined) + const result = (await amicodePost(props.server, "/amicode/project", { name: name() }).catch( + () => undefined, + )) as CreateResult | undefined + if (!result) { + setError("Couldn't reach the server — try again.") + return + } + if (!result.ok) { + setError(result.message) + return + } + dialog.close() + props.onCreated({ path: result.path, gitInitialized: result.gitInitialized }) + }, + })) + + function submit(e: SubmitEvent) { + e.preventDefault() + if (createMutation.isPending || !slug()) return + createMutation.mutate() + } + + return ( + +
+
+ + Creates a new folder in ~/AmicodeProjects.} + > + + Creates {slug()} in ~/AmicodeProjects, with git for change + tracking. + + +
+ + +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/pane-bridge.tsx b/packages/app-bundle/overlay/packages/app/src/components/pane-bridge.tsx new file mode 100644 index 00000000..7a798345 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/pane-bridge.tsx @@ -0,0 +1,63 @@ +import { createEffect, onCleanup } from "solid-js" +import { useLocation } from "@solidjs/router" +import { useTabs, tabHref, draftHref, type Tab } from "@/context/tabs" +import { useWorkbench, MAIN_PANE_ID } from "@/context/workbench" +import { AMICODE_PANE_ID, IS_AMICODE_PANE } from "@/utils/amicode-pane" +import { listenWorkbench, postWorkbench, type WorkbenchMessage } from "@/utils/pane-bridge" + +// amicode(workbench S2): every app instance mounts this. Two jobs: +// 1. REPORT — the instance's tab list (as route paths) flows to the parent's +// mirror whenever it changes: panes post over the bridge; the top document +// writes the workbench store directly (it IS the parent). +// 2. OBEY — panes execute the parent's open/close/focus commands via their +// own tabs context (the top document's commands are issued directly, no +// messages needed). +// Renders nothing. + +const tabsReport = (tabs: Tab[], activePath?: string) => ({ + tabs: tabs.map((t) => (t.type === "draft" ? draftHref(t.draftID) : tabHref(t))), + active: activePath, +}) + +export function WorkbenchBridge() { + const tabs = useTabs() + const location = useLocation() + const workbench = useWorkbench() + + const activePath = () => { + const path = `${location.pathname}${location.search}` + const list = tabs.store + return list.some((t) => (t.type === "draft" ? draftHref(t.draftID) : tabHref(t)) === path) ? path : undefined + } + + // REPORT + createEffect(() => { + const report = tabsReport(tabs.store, activePath()) + if (IS_AMICODE_PANE) { + postWorkbench(window.parent, { kind: "tabs-changed", paneId: AMICODE_PANE_ID ?? "pane", report }, "*") + } else { + workbench.report(MAIN_PANE_ID, report) + } + }) + + // OBEY (panes only — the parent commands the top document directly) + if (IS_AMICODE_PANE) { + const unlisten = listenWorkbench((msg: WorkbenchMessage) => { + if (msg.kind === "open-tab") tabs.openPath(msg.path, { activate: msg.activate }) + if (msg.kind === "close-tab") tabs.closePath(msg.path) + if (msg.kind === "focus-tab") tabs.openPath(msg.path, { activate: true }) + }) + onCleanup(unlisten) + } + + return null +} + +/** amicode(workbench): pane strips report drags to the parent (the parent owns + * drop resolution across frames). The top document uses the split context + * instead — this helper is pane-mode only. */ +export function reportTabDrag(path: string | undefined) { + if (!IS_AMICODE_PANE) return + if (path === undefined) postWorkbench(window.parent, { kind: "drag-tab-end" }, "*") + else postWorkbench(window.parent, { kind: "drag-tab-start", path }, "*") +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-clipboard-structure.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-clipboard-structure.test.ts new file mode 100644 index 00000000..399da81a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-clipboard-structure.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +// harmoniqs/amicode#261 — paste inserted the same text twice in the webview. +// +// Two mod+V handlers used to be live inside the framed app: +// 1. utils/global-clipboard.ts — window-level, CAPTURE phase. +// 2. The composer's own keydown handler (v1 handleKeyDown / v2 onKeyDown), +// which preventDefault'd but never stopPropagation'd, so BOTH bridge- +// inserted the same text. An opt-out marker (data-amc-clipboard="self") +// on the composer editors was tried, but the amicode fork hard-locks the +// v2 layout and the marker contract proved too fragile to maintain across +// the two composers. +// +// The current design removes the composers' keydown interception entirely: +// the window-level fallback is the SOLE ⌘V path in the webview (single +// insert), and the native paste event path (composer onPaste → handlePaste) +// remains for chords the fallback doesn't own (⌘⇧V). Image/screenshot paste +// over ⌘V is knowingly sacrificed — the fallback reads text only. +// +// This is a SOURCE assertion rather than a rendered-DOM one because the app has +// no component-render harness (no @solidjs/testing-library) — the same reason +// global-clipboard.test.ts exercises the fallback against a synthetic element +// it builds itself, which is exactly the gap that let #261 ship. Replace this +// with a render assertion the day a harness lands. +const source = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8") +const v2Interaction = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/interaction.ts"), + "utf8", +) +const v2Attachments = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/attachments.ts"), + "utf8", +) +const v2Editor = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/index.tsx"), + "utf8", +) +const globalClipboard = readFileSync(join(import.meta.dir, "../utils/global-clipboard.ts"), "utf8") + +describe("single ⌘V path in the webview (amicode#261)", () => { + test("the v1 composer no longer intercepts ⌘V at keydown", () => { + expect(source).not.toContain("inAmicode") + expect(source).not.toContain('event.key.toLowerCase() === "v"') + }) + + test("the v2 composer no longer intercepts ⌘V at keydown", () => { + expect(v2Interaction).not.toContain('event.key.toLowerCase() === "v"') + expect(v2Interaction).not.toContain("handleFramedPaste") + expect(v2Attachments).not.toContain("handleFramedPaste") + }) + + test("neither composer editor carries the opt-out marker", () => { + // The marker contract is gone: the fallback owns ⌘V everywhere. A marker + // here would silently re-orphan the composer (nothing would paste on ⌘V). + expect(source).not.toContain('data-amc-clipboard="self"') + expect(v2Editor).not.toContain('data-amc-clipboard="self"') + }) + + test("the window-level fallback still owns the ⌘V branch for editables", () => { + expect(globalClipboard).toContain('key !== "v"') + expect(globalClipboard).toContain("installGlobalClipboardFallback") + expect(globalClipboard).toContain("readClipboardViaBridge") + }) + + test("the composers still wire the native paste path with bridge fallbacks", () => { + // ⌘⇧V (and any non-prevented paste event) flows: onPaste → handlePaste, + // which falls back to the host-clipboard bridges when the event carries + // nothing readable. + expect(source).toContain("onPaste={handlePaste}") + expect(v2Interaction).toContain("handlePaste") + expect(source).toContain("readClipboardViaBridge") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 00000000..a66d1433 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,606 @@ +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import { ReportBugButton } from "@/components/report-bug-button" +import type { PromptInputProps } from "@/components/prompt-input/contracts" +import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" +import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" +import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" +import { readClipboardImageViaBridge, readClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +import { createPromptSubmit } from "@/components/prompt-input/submit" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { showToast } from "@/utils/toast" +import { bugReportEnabled } from "@/utils/amicode-bug-report" +import { setClipboardImageHandler } from "@/utils/global-clipboard" +import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" +import { + createPromptInputV2Controller, + createPromptInputV2State, + type PromptInputV2Interaction, +} from "@opencode-ai/session-ui/v2/prompt-input/interaction" + +export type PromptInputV2ComposerProps = { + class?: string + controller: PromptInputV2ComposerController + borderUnderlay?: boolean +} + +export type PromptInputV2ControllerProps = Omit +export type PromptInputV2ComposerController = PromptInputV2Interaction & { + readonly model: PromptInputProps["controls"]["model"] +} + +export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { + const dialog = useDialog() + const command = useCommand() + const language = useLanguage() + + return ( +
+ : undefined} + modelControl={ + + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { + const sdk = useSDK() + const sync = useSync() + const files = useFile() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const prompt = props.state ?? usePrompt() + let editor: HTMLDivElement | undefined + + const interaction = createPromptInputV2State() + const mode = () => interaction[0].mode + const history = props.history ?? createPersistedPromptInputHistory() + const tabs = () => props.controls.session.tabs + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((tab) => tab !== active)] : all + return order.reduce((result, tab) => { + const path = files.pathFromTab(tab) + if (!path || result.includes(path)) return result + return [...result, path] + }, []) + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const attachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + const commentCount = createMemo(() => { + if (mode() === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: mode(), + commentCount: commentCount(), + example: mode() === "shell" ? "git status" : "", + suggest: false, + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + const comment = item.comment?.trim() + if (!comment) return [] + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + const restoreHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file", + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + const submission = createPromptSubmit({ + prompt, + info, + imageAttachments: attachments, + commentCount, + autoAccept: accepting, + mode, + working, + editor: () => editor, + queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), + promptLength, + addToHistory: (value, mode) => controller.addHistory(value, mode), + resetHistoryNavigation: () => controller.resetHistory(), + setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), + setPopover: (popover) => { + if (!popover) controller.dispatch({ type: "popover.close" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + const references = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map((reference) => ({ + id: `reference:${reference.name}`, + kind: "reference" as const, + label: `@${reference.name}`, + path: reference.path, + description: reference.description ?? referenceDescription(reference), + mention: { + type: "file" as const, + path: reference.path, + content: `@${reference.name}`, + start: 0, + end: 0, + mime: "application/x-directory", + filename: reference.name, + }, + })), + ) + const resources = createMemo(() => + Object.values(sync().data.mcp_resource).map((resource) => ({ + id: `resource:${resource.server}:${resource.uri}`, + kind: "resource" as const, + label: `@${resource.name}`, + path: resource.uri, + description: resource.description, + mention: { + type: "file" as const, + path: resource.uri, + content: `@${resource.name}`, + start: 0, + end: 0, + mime: resource.mimeType ?? "text/plain", + filename: resource.name, + url: resource.uri, + source: { + type: "resource" as const, + text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, + clientName: resource.server, + uri: resource.uri, + }, + }, + resource, + })), + ) + const context = createMemo(() => [ + ...references(), + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + id: `agent:${agent.name}`, + kind: "agent" as const, + label: `@${agent.name}`, + mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, + })), + ...resources(), + ...recent().map((path) => ({ + id: `file:${path}`, + kind: "file" as const, + label: path, + path, + recent: true, + mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, + })), + ]) + const slashCommands = createMemo(() => [ + ...sync().data.command.map((item) => ({ + id: `custom.${item.name}`, + trigger: item.name, + title: item.name, + description: item.description, + type: "custom" as const, + })), + ...command.options + .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) + .map((item) => ({ + id: item.id, + trigger: item.slash!, + title: item.title, + description: item.description, + type: "builtin" as const, + })), + ]) + const commands = createMemo(() => + slashCommands().map((item) => ({ + id: item.id, + kind: "command", + label: `/${item.trigger}`, + trigger: item.trigger, + title: item.title, + description: item.description, + keybind: command.keybindParts(item.id), + })), + ) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + const controller = createPromptInputV2Controller({ + store: () => prompt.capture().store, + state: interaction, + identity: () => prompt.capture(), + history: { + entries: (mode) => + history.entries(mode).map((value) => { + const entry = normalizePromptHistoryEntry(value) + return { prompt: entry.prompt, metadata: entry.comments } + }), + add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), + capture: historyComments, + restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), + }, + commands, + context, + searchContextFiles: async (query) => + (await files.searchFilesAndDirectories(query)).map((path) => ({ + id: `file:${path}`, + kind: "file", + label: path, + path, + mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, + })), + onContextRemove(item) { + if (item?.commentID) comments.remove(item.path, item.commentID) + }, + openAttachment: (attachment) => + dialog.show(() => ), + openContext(key) { + const item = controller.contextItem(key) + if (item) openComment(item, props, sync, layout, files, comments) + }, + onEditor(element) { + editor = element as HTMLDivElement + props.ref?.(editor) + }, + onSuggestionSelect(item) { + if (item.kind !== "command") return + const selected = slashCommands().find((entry) => entry.id === item.id) + if (!selected || selected.type === "custom") return + return () => command.trigger(selected.id, "slash") + }, + attachments: { + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + isDialogActive: () => !!dialog.active, + warn: () => + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }), + duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + // Amicode webview (patch #11): the bridge reads the OS clipboard through + // the extension host where the framed app has no clipboard permission. + // Bridge first (self-gates to null unframed), platform as fallback. + readClipboardImage: async () => (await readClipboardImageViaBridge()) ?? (await platform.readClipboardImage?.()) ?? null, + // Text side of the same bridge — feeds session-ui's handlePaste framed + // text fallback when the paste event carries nothing readable. + readClipboardText: () => readClipboardViaBridge(), + getPathForFile: platform.getPathForFile, + store: platform.draftStore?.putBlob, + }, + view: { + placeholder: designPlaceholder, + get agent() { + return props.controls.agents.visible && props.controls.agents.options.length > 0 + ? { + options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), + current: () => props.controls.agents.current, + onSelect: (value: string) => props.controls.agents.select(value), + keybind: () => command.keybindParts("agent.cycle"), + } + : undefined + }, + variant: { + options: () => variants().map((value) => ({ id: value, label: value })), + current: () => props.controls.model.selection.variant.current() ?? "default", + onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), + keybind: () => command.keybindParts("model.variant.cycle"), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + Object.defineProperty(controller, "model", { get: () => props.controls.model }) + + // Framed webview: the window-level fallback (global-clipboard.ts) is the + // sole ⌘V owner. When the clipboard carries no text it offers the media to + // this slot, landing it in the composer's attachment pipeline. + setClipboardImageHandler((file) => controller.addAttachments([file])) + onCleanup(() => setClipboardImageHandler(undefined)) + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: controller.state.mode !== "normal", + onSelect: () => controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: controller.state.mode === "shell", + onSelect: () => controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: controller.state.mode === "normal", + onSelect: () => controller.dispatch({ type: "mode.normal" }), + }, + ]) + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + controller.dispatch({ type: "mode.normal" }) + controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + + return controller as PromptInputV2ComposerController +} + +function PromptInputV2ModelControl(props: { + loading: boolean + paid: boolean + title: string + keybind: string[] + model: PromptInputV2ComposerController["model"]["selection"] + providerID?: string + modelName: string + onClose: () => void + onUnpaidClick: () => void +}) { + const shouldAnimate = createMemo((previous) => previous ?? props.loading) + const content = () => ( + <> + + {(providerID) => ( + + )} + + {props.modelName} + + + + + ) + return ( + + + {props.title} + + + } + > + + {content()} + + } + > + ( + + {content()} + + )} + onClose={props.onClose} + /> + + + + ) +} + +function openComment( + item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, + props: PromptInputV2ControllerProps, + sync: ReturnType, + layout: ReturnType, + files: ReturnType, + comments: ReturnType, +) { + if (!item.commentID) return + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + const queueFocus = (attempts = 6) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (attempts <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) + }) + }) + } + const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined + const review = + item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + if (review) { + layout.fileTree.setTab("changes") + props.controls.session.tabs.setActive("review") + queueFocus() + return + } + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void props.controls.session.tabs.open(tab) + props.controls.session.tabs.setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx new file mode 100644 index 00000000..9d0b15a9 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx @@ -0,0 +1,1789 @@ +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { + createEffect, + on, + Component, + Show, + onCleanup, + createMemo, + createSignal, + createResource, + Switch, + Match, + type JSX, +} from "solid-js" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { + ContentPart, + DEFAULT_PROMPT, + isCommentItem, + isPromptEqual, + Prompt, + usePrompt, + ImageAttachmentPart, + AgentPart, + FileAttachmentPart, +} from "@/context/prompt" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useComments } from "@/context/comments" +import { Button } from "@opencode-ai/ui/button" +import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" +import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Select } from "@opencode-ai/ui/select" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import { useCommand } from "@/context/command" +import { usePermission } from "@/context/permission" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { createSessionTabs } from "@/pages/session/helpers" +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { createPromptAttachments } from "./prompt-input/attachments" +import { readClipboardImageViaBridge, readClipboardViaBridge } from "./prompt-input/clipboard-bridge" +import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" +import { + canNavigateHistoryAtCursor, + navigatePromptHistory, + type PromptHistoryComment, + type PromptHistoryEntry, + promptLength, +} from "./prompt-input/history" +import { + createPersistedPromptInputHistory, + createPromptInputHistory, + type PromptInputHistory, +} from "./prompt-input/history-store" +import { + type PromptInputControls, + type PromptInputProps, + type PromptInputState, + type PromptInputSubmission, +} from "./prompt-input/contracts" +import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptImageAttachments } from "./prompt-input/image-attachments" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" +import { createPromptInputTransientState } from "./prompt-input/transient-state" +import { showToast } from "@/utils/toast" +import { hiddenProjectWorktree } from "@/utils/amicode-hidden-project" +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { setClipboardImageHandler } from "@/utils/global-clipboard" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" + +export { createPromptInputHistory } +export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } + +// amicode: rotation trimmed to the five pulse-design examples (en.ts values +// rebranded; keys 6-25 left dormant in the dictionaries for easy revert) +const EXAMPLES = [ + "prompt.example.1", + "prompt.example.2", + "prompt.example.3", + "prompt.example.4", + "prompt.example.5", +] as const + +export const PromptInput: Component = (props) => { + const sdk = useSDK() + + const sync = useSync() + const files = useFile() + const prompt = props.state ?? usePrompt() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const tabs = () => props.controls.session.tabs + let editorRef!: HTMLDivElement + let fileInputRef: HTMLInputElement | undefined + let scrollRef!: HTMLDivElement + let slashPopoverRef!: HTMLDivElement + let restoreEndOnFocus = true + let savedCursor: number | null = null + + const mirror = { input: false } + const inset = 56 + const space = `${inset}px` + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const cursor = getCursorPosition(editorRef) + const length = promptLength(prompt.current().filter((part) => part.type !== "image")) + if (cursor >= length) { + container.scrollTop = container.scrollHeight + return + } + + const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - inset) { + container.scrollTop = bottom - container.clientHeight + inset + } + } + + const queueScroll = (count = 2) => { + requestAnimationFrame(() => { + scrollCursorIntoView() + if (count > 1) queueScroll(count - 1) + }) + } + + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + + const commentInReview = (path: string) => { + const sessionID = props.controls.session.id + if (!sessionID) return false + + const diffs = sync().data.session_diff[sessionID] + if (!diffs) return false + return diffs.some((diff) => diff.file === path) + } + + const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { + if (!item.commentID) return + + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + + const queueCommentFocus = (attempts = 6) => { + const schedule = (left: number) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (left <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (!current) return + if (current.file !== focus.file || current.id !== focus.id) return + schedule(left - 1) + }) + }) + } + + schedule(attempts) + } + + const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) + if (wantsReview) { + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + layout.fileTree.setTab("changes") + tabs().setActive("review") + queueCommentFocus() + return + } + + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void tabs().open(tab) + tabs().setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) + } + + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((x) => x !== active)] : all + const seen = new Set() + const paths: string[] = [] + + for (const tab of order) { + const path = files.pathFromTab(tab) + if (!path) continue + if (seen.has(path)) continue + seen.add(path) + paths.push(path) + } + + return paths + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + + const [store, setStore] = createPromptInputTransientState( + () => prompt.capture(), + Math.floor(Math.random() * EXAMPLES.length), + ) + const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + const motion = (value: number) => ({ + opacity: value, + transform: `scale(${0.98 + value * 0.02})`, + filter: `blur(${(1 - value) * 2}px)`, + "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), + }) + const buttons = createMemo(() => motion(buttonsSpring())) + const shell = createMemo(() => motion(1 - buttonsSpring())) + const control = createMemo(() => ({ height: "28px", ...buttons() })) + + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const tip = () => { + if (stopping()) { + return ( +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+ ) + } + + return ( +
+ {language.t("prompt.action.send")} + +
+ ) + } + + const contextItems = createMemo(() => { + const items = prompt.context.items() + if (store.mode !== "shell") return items + return items.filter((item) => !item.comment?.trim()) + }) + + const hasUserPrompt = createMemo(() => { + const sessionID = props.controls.session.id + if (!sessionID) return false + const messages = sync().data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) + + const history = props.history ?? createPersistedPromptInputHistory() + + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + if (item.type !== "file") return [] + const comment = item.comment?.trim() + if (!comment) return [] + + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ + start: item.selection.startLine, + end: item.selection.endLine, + } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + + const applyHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file" as const, + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { + const p = entry.prompt + const length = position === "start" ? 0 : promptLength(p) + setStore("applyingHistory", true) + applyHistoryComments(entry.comments) + prompt.set(p, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + setStore("applyingHistory", false) + queueScroll() + }) + } + + const getCaretState = () => { + const selection = window.getSelection() + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } + return { + collapsed: selection.isCollapsed, + cursorPosition: getCursorPosition(editorRef), + textLength, + } + } + + const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + + const pick = () => { + pickAttachmentFiles({ + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + fallback: () => fileInputRef?.click(), + onFile: addAttachment, + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + }) + } + + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + requestAnimationFrame(() => editorRef?.focus()) + } + + const shellModeKey = "mod+shift+x" + const normalModeKey = "mod+shift+e" + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: store.mode !== "normal", + onSelect: pick, + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: shellModeKey, + disabled: store.mode === "shell", + onSelect: () => setMode("shell"), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: normalModeKey, + disabled: store.mode === "normal", + onSelect: () => setMode("normal"), + }, + ]) + + const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + + const resetHistoryNavigation = (force = false) => { + if (!force && (store.historyIndex < 0 || store.applyingHistory)) return + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + + const clearEditor = () => { + editorRef.innerHTML = "" + } + + const setEditorText = (text: string) => { + clearEditor() + editorRef.textContent = text + } + + const focusEditorEnd = () => { + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + } + + const currentCursor = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null + return getCursorPosition(editorRef) + } + + const restoreFocus = () => { + requestAnimationFrame(() => { + const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) + editorRef.focus() + setCursorPosition(editorRef, cursor) + queueScroll() + }) + } + + const handleFocus = () => { + if (!restoreEndOnFocus) return + restoreEndOnFocus = false + requestAnimationFrame(() => { + if (document.activeElement !== editorRef) return + setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) + queueScroll() + }) + } + + const renderEditorWithCursor = (parts: Prompt) => { + const cursor = currentCursor() + renderEditor(parts) + if (cursor !== null) setCursorPosition(editorRef, cursor) + } + + createEffect(() => { + props.controls.session.id + if (props.controls.session.id) return + if (!suggest()) return + const interval = setInterval(() => { + setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) + }, 6500) + onCleanup(() => clearInterval(interval)) + }) + + const [composing, setComposing] = createSignal(false) + const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + + const handleBlur = () => { + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) + closePopover() + setComposing(false) + } + + const handleCompositionStart = () => { + setComposing(true) + } + + const handleCompositionEnd = () => { + setComposing(false) + requestAnimationFrame(() => { + if (composing()) return + reconcile(prompt.current().filter((part) => part.type !== "image")) + }) + } + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + + const referenceList = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map( + (reference): AtOption => ({ + type: "reference", + name: reference.name, + path: reference.path, + display: reference.name, + description: reference.description ?? referenceDescription(reference), + }), + ), + ) + + const agentList = createMemo(() => + props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + ) + + const mcpResourceList = createMemo(() => + Object.values(sync().data.mcp_resource).map( + (resource): AtOption => ({ + type: "resource", + name: resource.name, + uri: resource.uri, + client: resource.server, + display: resource.name, + description: resource.description, + mime: resource.mimeType, + }), + ), + ) + + const handleAtSelect = (option: AtOption | undefined) => { + if (!option) return + if (option.type === "agent") { + addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) + return + } + if (option.type === "reference") { + addPart({ + type: "file", + path: option.path, + content: "@" + option.name, + start: 0, + end: 0, + mime: "application/x-directory", + filename: option.name, + }) + return + } + if (option.type === "resource") { + addPart({ + type: "file", + path: option.uri, + content: "@" + option.name, + start: 0, + end: 0, + mime: option.mime ?? "text/plain", + filename: option.name, + url: option.uri, + source: { + type: "resource", + text: { value: "@" + option.name, start: 0, end: 0 }, + clientName: option.client, + uri: option.uri, + }, + }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + } + + const atKey = (x: AtOption | undefined) => { + if (!x) return "" + if (x.type === "agent") return `agent:${x.name}` + if (x.type === "reference") return `reference:${x.name}` + if (x.type === "resource") return `resource:${x.client}:${x.uri}` + return `file:${x.path}` + } + + const { + flat: atFlat, + active: atActive, + setActive: setAtActive, + onInput: atOnInput, + onKeyDown: atOnKeyDown, + } = useFilteredList({ + items: async (query) => { + const references = referenceList() + const agents = agentList() + const mcpResources = mcpResourceList() + const open = recent() + const seen = new Set(open) + const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] + const paths = await files.searchFilesAndDirectories(query) + const fileOptions: AtOption[] = paths + .filter((path) => !seen.has(path)) + .map((path) => ({ type: "file", path, display: path })) + return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] + }, + key: atKey, + filterKeys: ["display"], + skipFilter: (item) => item.type === "file" && !item.recent, + groupBy: (item) => { + if (item.type === "reference") return "reference" + if (item.type === "agent") return "agent" + if (item.type === "resource") return "resource" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const rank = (category: string) => { + if (category === "reference") return 0 + if (category === "agent") return 1 + if (category === "resource") return 2 + if (category === "recent") return 3 + return 4 + } + return rank(a.category) - rank(b.category) + }, + onSelect: handleAtSelect, + }) + + const slashCommands = createMemo(() => { + const builtin = command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + type: "builtin" as const, + })) + + const custom = sync().data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + // source: cmd.source, + })) + + return [...custom, ...builtin] + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + const menu = store.slashMenu + closePopover() + const images = imageAttachments() + + if (cmd.type === "custom") { + const text = `/${cmd.trigger} ` + if (menu) { + editorRef.focus() + setCursorPosition(editorRef, 0) + addPart({ type: "text", content: text, start: 0, end: text.length }) + focusEditorEnd() + return + } + setEditorText(text) + prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) + focusEditorEnd() + return + } + + if (menu) { + command.trigger(cmd.id, "slash") + return + } + + clearEditor() + prompt.set([...DEFAULT_PROMPT, ...images], 0) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + setActive: setSlashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: slashCommands, + key: (x) => x?.id, + filterKeys: ["trigger", "title"], + onSelect: handleSlashSelect, + }) + + const createPill = (part: FileAttachmentPart | AgentPart) => { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", part.type) + if (part.type === "file") { + pill.setAttribute("data-path", part.path) + if (part.mime) pill.setAttribute("data-mime", part.mime) + if (part.filename) pill.setAttribute("data-filename", part.filename) + if (part.url) pill.setAttribute("data-url", part.url) + if (part.source?.type === "resource") { + pill.setAttribute("data-source-type", part.source.type) + pill.setAttribute("data-source-client-name", part.source.clientName) + pill.setAttribute("data-source-uri", part.source.uri) + } + } + if (part.type === "agent") pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + return pill + } + + const isNormalizedEditor = () => + Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + return !!prevIsBr && !next + } + if (node.nodeType !== Node.ELEMENT_NODE) return false + const el = node as HTMLElement + if (el.dataset.type === "file") return true + if (el.dataset.type === "agent") return true + return el.tagName === "BR" + }) + + const renderEditor = (parts: Prompt) => { + clearEditor() + for (const part of parts) { + if (part.type === "text") { + editorRef.appendChild(createTextFragment(part.content)) + continue + } + if (part.type === "file" || part.type === "agent") { + editorRef.appendChild(createPill(part)) + } + } + + const last = editorRef.lastChild + if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { + editorRef.appendChild(document.createTextNode("\u200B")) + } + } + + const scrollSlashActiveIntoView = () => { + const activeId = slashActive() + if (!activeId || !slashPopoverRef) return + + requestAnimationFrame(() => { + const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + } + const selectPopoverActive = () => { + if (store.popover === "at") { + const items = atFlat() + if (items.length === 0) return + const active = atActive() + const item = items.find((entry) => atKey(entry) === active) ?? items[0] + handleAtSelect(item) + return + } + + if (store.popover === "slash") { + const items = slashFlat() + if (items.length === 0) return + const active = slashActive() + const item = items.find((entry) => entry.id === active) ?? items[0] + handleSlashSelect(item) + } + } + + const reconcile = (input: Prompt) => { + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return + + renderEditorWithCursor(input) + return + } + + const dom = parseFromDOM() + if (isNormalizedEditor() && isPromptEqual(input, dom)) return + + renderEditorWithCursor(input) + } + + createEffect( + on( + () => prompt.current(), + (parts) => { + if (composing()) return + reconcile(parts.filter((part) => part.type !== "image")) + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const parts: Prompt = [] + let position = 0 + let buffer = "" + + const flushText = () => { + let content = buffer + if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") + if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") + buffer = "" + if (!content) return + parts.push({ type: "text", content, start: position, end: position + content.length }) + position += content.length + } + + const pushFile = (file: HTMLElement) => { + const content = file.textContent ?? "" + const source = + file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri + ? { + type: "resource" as const, + text: { + value: content, + start: position, + end: position + content.length, + }, + clientName: file.dataset.sourceClientName, + uri: file.dataset.sourceUri, + } + : undefined + parts.push({ + type: "file", + path: file.dataset.path!, + content, + start: position, + end: position + content.length, + ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), + ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), + ...(file.dataset.url ? { url: file.dataset.url } : {}), + ...(source ? { source } : {}), + }) + position += content.length + } + + const pushAgent = (agent: HTMLElement) => { + const content = agent.textContent ?? "" + parts.push({ + type: "agent", + name: agent.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const el = node as HTMLElement + if (el.dataset.type === "file") { + flushText() + pushFile(el) + return + } + if (el.dataset.type === "agent") { + flushText() + pushAgent(el) + return + } + if (el.tagName === "BR") { + buffer += "\n" + return + } + + for (const child of Array.from(el.childNodes)) { + visit(child) + } + } + + const children = Array.from(editorRef.childNodes) + children.forEach((child, index) => { + const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) + visit(child) + if (isBlock && index < children.length - 1) { + buffer += "\n" + } + }) + + flushText() + + if (parts.length === 0) parts.push(...DEFAULT_PROMPT) + return parts + } + + const handleInput = () => { + const rawParts = parseFromDOM() + const images = imageAttachments() + const cursorPosition = getCursorPosition(editorRef) + const rawText = + rawParts.length === 1 && rawParts[0]?.type === "text" + ? rawParts[0].content + : rawParts.map((p) => ("content" in p ? p.content : "")).join("") + const hasNonText = rawParts.some((part) => part.type !== "text") + const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "") + const shouldReset = + textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0 + + if (shouldReset) { + closePopover() + resetHistoryNavigation() + if (prompt.dirty()) { + mirror.input = true + prompt.set(DEFAULT_PROMPT, 0) + } + queueScroll() + return + } + + const shellMode = store.mode === "shell" + + if (!shellMode) { + const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + + if (atMatch) { + atOnInput(atMatch[1]) + setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) + } else if (slashMatch) { + slashOnInput(slashMatch[1]) + setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) + } else { + closePopover() + } + } else { + closePopover() + } + + resetHistoryNavigation() + + mirror.input = true + prompt.set([...rawParts, ...images], cursorPosition) + queueScroll() + } + + const addPart = (part: ContentPart) => { + if (part.type === "image") return false + + const selection = window.getSelection() + if (!selection) return false + + if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { + editorRef.focus() + const cursor = prompt.cursor() ?? promptLength(prompt.current()) + setCursorPosition(editorRef, cursor) + } + + if (selection.rangeCount === 0) return false + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return false + + if (part.type === "file" || part.type === "agent") { + const cursorPosition = getCursorPosition(editorRef) + const rawText = prompt + .current() + .map((p) => ("content" in p ? p.content : "")) + .join("") + const textBeforeCursor = rawText.substring(0, cursorPosition) + const atMatch = textBeforeCursor.match(/@(\S*)$/) + const pill = createPill(part) + const gap = document.createTextNode(" ") + + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursorPosition) + } + + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + if (part.type === "text") { + const fragment = createTextFragment(part.content) + const last = fragment.lastChild + range.deleteContents() + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" + const next = last.nextSibling + const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" + if (isBreak && (!next || emptyText)) { + const placeholder = next && emptyText ? next : document.createTextNode("\u200B") + if (!next) last.parentNode?.insertBefore(placeholder, null) + placeholder.textContent = "\u200B" + range.setStart(placeholder, 0) + } else { + range.setStartAfter(last) + } + } + } + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + handleInput() + closePopover() + return true + } + + const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { + history.add(prompt, mode, mode === "shell" ? [] : historyComments()) + } + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) + } + + for (const item of edit.context) { + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + + setStore("mode", "normal") + closePopover() + setStore("historyIndex", -1) + setStore("savedPrompt", null) + prompt.set(edit.prompt, promptLength(edit.prompt)) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(edit.prompt)) + queueScroll() + }) + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + + const navigateHistory = (direction: "up" | "down") => { + const result = navigatePromptHistory({ + direction, + entries: history.entries(store.mode), + historyIndex: store.historyIndex, + currentPrompt: prompt.current(), + currentComments: historyComments(), + savedPrompt: store.savedPrompt, + }) + if (!result.handled) return false + setStore("historyIndex", result.historyIndex) + setStore("savedPrompt", result.savedPrompt) + applyHistoryPrompt(result.entry, result.cursor) + return true + } + + const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ + prompt, + editor: () => editorRef, + isDialogActive: () => !!dialog.active, + setDraggingType: (type) => setStore("draggingType", type), + focusEditor: () => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(prompt.current())) + }, + addPart, + // Amicode webview: image on the OS clipboard is only readable via the outer + // webview's navigator.clipboard.read() (chat_panel.ts). Bridge first so + // Ctrl+V in the iframe attaches the image directly (har-moniqs/amicode#365); + // platform fallback preserves desktop/TUI native clipboard. + readClipboardImage: async () => (await readClipboardImageViaBridge()) ?? (await platform.readClipboardImage?.()) ?? null, + // Webview-iframe paste fallback; self-gates to a no-op outside the webview. + readClipboardText: () => readClipboardViaBridge(), + getPathForFile: platform.getPathForFile, + }) + + // Framed webview: the window-level fallback (global-clipboard.ts) is the + // sole ⌘V owner. When the clipboard carries no text it offers the media to + // this slot, landing it in the composer's attachment pipeline (mirrors v2). + setClipboardImageHandler((file) => void addAttachments([file])) + onCleanup(() => setClipboardImageHandler(undefined)) + + const fileAttachmentInput = () => ( + (fileInputRef = el)} + type="file" + multiple + accept={ACCEPTED_FILE_TYPES.join(",")} + class="hidden" + onChange={(e) => { + const list = e.currentTarget.files + if (list) void addAttachments(Array.from(list)) + e.currentTarget.value = "" + }} + /> + ) + + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + // Check provider variants directly: `variants` also includes the UI-only default option. + const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + + const { abort, handleSubmit } = + props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => { + if (!popover) return closePopover() + setStore({ popover, slashMenu: false, slashMenuQuery: "" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { + event.preventDefault() + if (store.mode !== "normal") return + pick() + return + } + + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + + if (event.key === "!" && store.mode === "normal") { + const cursorPosition = getCursorPosition(editorRef) + if (cursorPosition === 0) { + setStore("mode", "shell") + closePopover() + event.preventDefault() + return + } + } + + if (event.key === "Escape") { + if (store.popover) { + closePopover() + event.preventDefault() + event.stopPropagation() + return + } + + if (store.mode === "shell") { + setStore("mode", "normal") + event.preventDefault() + event.stopPropagation() + return + } + + if (working()) { + void abort() + event.preventDefault() + event.stopPropagation() + return + } + + if (escBlur()) { + editorRef.blur() + event.preventDefault() + event.stopPropagation() + return + } + } + + if (store.mode === "shell") { + const { collapsed, cursorPosition, textLength } = getCaretState() + if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { + setStore("mode", "normal") + event.preventDefault() + return + } + } + + // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input + // and should always insert a newline regardless of composition state + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + + if (event.key === "Enter" && isImeComposing(event)) { + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + + if (store.popover) { + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (nav || ctrlNav) { + if (store.popover === "at") { + atOnKeyDown(event) + event.preventDefault() + return + } + if (store.popover === "slash") { + slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { + scrollSlashActiveIntoView() + } + } + event.preventDefault() + return + } + } + + if (ctrl && event.code === "KeyG") { + if (store.popover) { + closePopover() + event.preventDefault() + return + } + if (working()) { + void abort() + event.preventDefault() + } + return + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + if (event.altKey || event.ctrlKey || event.metaKey) return + const { collapsed } = getCaretState() + if (!collapsed) return + + const cursorPosition = getCursorPosition(editorRef) + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + const direction = event.key === "ArrowUp" ? "up" : "down" + if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return + if (navigateHistory(direction)) { + event.preventDefault() + } + return + } + + // Note: Shift+Enter is handled earlier, before IME check + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + if (event.repeat) return + if ( + working() && + prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim().length === 0 && + imageAttachments().length === 0 && + commentCount() === 0 + ) { + return + } + void handleSubmit(event) + } + } + + const handleSlashMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePopover() + requestAnimationFrame(() => editorRef.focus()) + event.preventDefault() + return + } + + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (!nav && !ctrlNav) return + slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() + event.preventDefault() + } + + const agentsLoading = () => props.controls.agents.loading + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) + const providersLoading = () => props.controls.model.loading + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + + const [promptReady] = createResource( + () => prompt.ready.promise, + (p) => p, + ) + + const bindEditorRef = (el: HTMLDivElement) => { + editorRef = el + restoreEndOnFocus = true + props.ref?.(el) + } + return ( +
+ {(promptReady(), null)} + (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + slashMenu={store.slashMenu} + slashMenuQuery={store.slashMenuQuery} + onSlashMenuInput={(value) => { + setStore("slashMenuQuery", value) + slashOnInput(value) + }} + onSlashMenuKeyDown={handleSlashMenuKeyDown} + commandKeybind={command.keybind} + commandKeybindParts={command.keybindParts} + newLayoutDesigns={false} + t={(key) => language.t(key as Parameters[0])} + /> + + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + newLayoutDesigns={false} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={false} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)} + style={{ "scroll-padding-bottom": space }} + > +
+
+ {placeholder()} +
+
+ + + + + +
+
+
+ + {language.t("prompt.mode.shell")} +
+ +
+
+ +
+ + (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + props.controls.model.selection.variant.set(value === "default" ? undefined : value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> + +
+
+ + +
+
+
+ + +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/attachments.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/attachments.ts new file mode 100644 index 00000000..29059326 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/attachments.ts @@ -0,0 +1,226 @@ +import { onMount } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import { showToast } from "@/utils/toast" +import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { uuid } from "@/utils/uuid" +import { getCursorPosition } from "./editor-dom" +import { createBlobReference, type DraftStore } from "@/utils/draft-store" +import { attachmentMime } from "./files" +import { normalizePaste, pasteMode } from "./paste" + +type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> +type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } + +type PromptAttachmentsCoreInput = { + capture: () => PromptTarget + editor: () => HTMLDivElement | undefined + focusEditor?: () => void + addPart?: (part: ContentPart) => boolean + warn?: () => void + readClipboardImage?: () => Promise + /** Fallback clipboard-text reader for the VS Code webview iframe (see + * clipboard-bridge); resolves "" when unavailable. */ + readClipboardText?: () => Promise + getPathForFile?: (file: File) => string + draftStore?: DraftStore +} + +export type PromptAttachmentsInput = { + prompt: ReturnType + editor: () => HTMLDivElement | undefined + isDialogActive: () => boolean + setDraggingType: (type: "image" | "@mention" | null) => void + focusEditor: () => void + addPart: (part: ContentPart) => boolean + readClipboardImage?: () => Promise + /** Fallback clipboard-text reader for the VS Code webview iframe, where native + * paste delivers no data. Resolves "" when unavailable (see clipboard-bridge). */ + readClipboardText?: () => Promise + getPathForFile?: (file: File) => string +} + +export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { + const capture = (): AttachmentTarget | undefined => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } + } + + const add = async (file: File, toast = true, target = capture()) => { + if (!target) return false + const mime = await attachmentMime(file) + if (!mime) { + if (toast) input.warn?.() + return false + } + + const attachment: ImageAttachmentPart = { + type: "image", + id: uuid(), + filename: file.name, + sourcePath: input.getPathForFile?.(file) || undefined, + mime, + blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file), + } + target.prompt.set([...target.prompt.current(), attachment], target.cursor) + return true + } + + const addAttachment = (file: File) => add(file) + + const addAttachments = async (files: File[], toast = true, target = capture()) => { + let found = false + + for (const file of files) { + const ok = await add(file, false, target) + if (ok) found = true + } + + if (!found && files.length > 0 && toast) input.warn?.() + return found + } + + const addClipboardAttachment = async (pending: Promise, target = capture()) => { + const file = await pending + if (!file) return false + return add(file, true, target) + } + + const removeAttachment = (id: string) => { + const target = input.capture() + const current = target.current() + const next = current.filter((part) => part.type !== "image" || part.id !== id) + target.set(next, target.cursor()) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + const target = capture() + if (!target) return + + event.preventDefault() + event.stopPropagation() + + const files = Array.from(clipboardData.items).flatMap((item) => { + if (item.kind !== "file") return [] + const file = item.getAsFile() + return file ? [file] : [] + }) + + if (files.length > 0) { + await addAttachments(files, true, target) + return + } + + let plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + if (await addClipboardAttachment(input.readClipboardImage(), target)) return + } + + // Amicode webview: a cross-origin iframe inside the VS Code webview gets no + // clipboard data from native paste, so ask the extension host over the + // amicode bridge before giving up (resolves "" outside the webview). + if (!plainText && input.readClipboardText) { + plainText = await input.readClipboardText() + } + + if (!plainText) return + + const text = normalizePaste(plainText) + + const put = () => { + if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor?.() + return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false + } + + if (pasteMode(text) === "manual") { + put() + return + } + + const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) + if (inserted) return + + put() + } + + return { + addAttachment, + addAttachments, + addClipboardAttachment, + removeAttachment, + handlePaste, + } +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const language = useLanguage() + const platform = usePlatform() + const attachments = createPromptAttachmentsCore({ + ...input, + draftStore: platform.draftStore, + capture: input.prompt.capture, + warn: () => { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + }, + }) + + const handleGlobalDragOver = (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + const hasText = event.dataTransfer?.types.includes("text/plain") + if (hasFiles) { + input.setDraggingType("image") + } else if (hasText) { + input.setDraggingType("@mention") + } + } + + const handleGlobalDragLeave = (event: DragEvent) => { + if (input.isDialogActive()) return + if (!event.relatedTarget) { + input.setDraggingType(null) + } + } + + const handleGlobalDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + input.setDraggingType(null) + + const plainText = event.dataTransfer?.getData("text/plain") + const filePrefix = "file:" + if (plainText?.startsWith(filePrefix)) { + const filePath = plainText.slice(filePrefix.length) + input.focusEditor() + input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) + return + } + + const dropped = event.dataTransfer?.files + if (!dropped) return + + await attachments.addAttachments(Array.from(dropped)) + } + + onMount(() => { + makeEventListener(document, "dragover", handleGlobalDragOver) + makeEventListener(document, "dragleave", handleGlobalDragLeave) + makeEventListener(document, "drop", handleGlobalDrop) + }) + + return attachments +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/clipboard-bridge.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/clipboard-bridge.test.ts new file mode 100644 index 00000000..e12d45bd --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/clipboard-bridge.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test" +import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge" + +type Listener = (event: MessageEvent) => void + +// A stand-in for a framed window (parent !== self) that records outgoing +// clipboard-requests and lets a test play back the host's reply. `readText` +// simulates the host's navigator.clipboard grant: a function to grant it, +// absent to model the VS Code webview iframe (no permission). +function fakeFramedWindow(readText?: () => Promise) { + const listeners = new Set() + const posted: Array> = [] + const win = { + addEventListener: (_type: string, fn: Listener) => listeners.add(fn), + removeEventListener: (_type: string, fn: Listener) => listeners.delete(fn), + navigator: readText ? { clipboard: { readText } } : {}, + parent: { + postMessage: (message: Record) => posted.push(message), + }, + } as unknown as Window + return { + win, + posted, + reply: (message: Record) => listeners.forEach((fn) => fn({ data: message } as MessageEvent)), + listenerCount: () => listeners.size, + } +} + +describe("readClipboardViaBridge", () => { + test("requests the OS clipboard from the host and resolves with the reply", async () => { + const bridge = fakeFramedWindow() + const pending = readClipboardViaBridge(bridge.win) + + expect(bridge.posted).toHaveLength(1) + const request = bridge.posted[0] + expect(request.source).toBe("amicode") + expect(request.kind).toBe("clipboard-request") + expect(typeof request.nonce).toBe("string") + + bridge.reply({ source: "amicode", kind: "clipboard", nonce: request.nonce, text: "solve a CZ gate" }) + + expect(await pending).toBe("solve a CZ gate") + expect(bridge.listenerCount()).toBe(0) // listener cleaned up + }) + + test("ignores replies whose nonce does not match the request", async () => { + const bridge = fakeFramedWindow() + const pending = readClipboardViaBridge(bridge.win, 15) + + // A stale reply from an earlier request must not resolve this one. + bridge.reply({ source: "amicode", kind: "clipboard", nonce: "someone-elses-nonce", text: "leaked" }) + + expect(await pending).toBe("") // falls through to the timeout instead + }) + + test("resolves empty on a malformed reply body", async () => { + const bridge = fakeFramedWindow() + const pending = readClipboardViaBridge(bridge.win, 15) + const request = bridge.posted[0] + + bridge.reply({ source: "amicode", kind: "clipboard", nonce: request.nonce }) // no text field + + expect(await pending).toBe("") + }) + + test("framed host WITHOUT the amicode relay (Simple Browser, plain embeds) falls back to navigator.clipboard", async () => { + // No reply ever comes — the request went into the void. The API key being + // pasted must still arrive via the host's own clipboard-read grant. + const bridge = fakeFramedWindow(() => Promise.resolve("sk-cloud-key-123")) + expect(await readClipboardViaBridge(bridge.win, 15)).toBe("sk-cloud-key-123") + }) + + test("a live bridge reply wins — navigator.clipboard is not consulted", async () => { + const bridge = fakeFramedWindow(() => { + throw new Error("must not be called when the bridge answered") + }) + const pending = readClipboardViaBridge(bridge.win) + const request = bridge.posted[0] + bridge.reply({ source: "amicode", kind: "clipboard", nonce: request.nonce, text: "from the bridge" }) + expect(await pending).toBe("from the bridge") + }) + + test("no relay AND no clipboard permission resolves empty (the caller's no-op stands)", async () => { + const bridge = fakeFramedWindow(() => Promise.reject(new Error("NotAllowedError"))) + expect(await readClipboardViaBridge(bridge.win, 15)).toBe("") + }) + + test("resolves empty without posting when the app is not framed", async () => { + const posted: unknown[] = [] + const win = { + addEventListener: () => {}, + removeEventListener: () => {}, + postMessage: (message: unknown) => posted.push(message), + } as unknown as Window + // parent === self → not inside a webview iframe + ;(win as unknown as { parent: Window }).parent = win + + expect(await readClipboardViaBridge(win)).toBe("") + expect(posted).toHaveLength(0) + }) +}) + +describe("writeClipboardViaBridge", () => { + test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => { + const bridge = fakeFramedWindow() + + expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true) + expect(bridge.posted).toHaveLength(1) + const message = bridge.posted[0] + expect(message.source).toBe("amicode") + expect(message.kind).toBe("clipboard-write") + expect(message.text).toBe("solve a CZ gate") + }) + + test("is a no-op that posts nothing when the app is not framed", () => { + const posted: unknown[] = [] + const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window + // parent === self → plain web/desktop, where native copy already works + ;(win as unknown as { parent: Window }).parent = win + + expect(writeClipboardViaBridge("ignored", win)).toBe(false) + expect(posted).toHaveLength(0) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/clipboard-bridge.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/clipboard-bridge.ts new file mode 100644 index 00000000..54ef6f58 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/clipboard-bridge.ts @@ -0,0 +1,128 @@ +// ⌘V inside the Amicode chat: the app runs as a cross-origin iframe inside the +// VS Code webview, where native paste and navigator.clipboard deliver no data +// (the webview parent holds no clipboard-read permission to delegate down). The +// extension host CAN read it, so we ask over the amicode postMessage bridge — +// chat_panel.ts reads vscode.env.clipboard and replies with {kind:"clipboard"}. +// Mirrors the profile-input fallback in @opencode-ai/ui's home-cards. +// +// Resolves "" when unframed (plain web/desktop, where native paste already +// works), on a malformed reply, or after `timeoutMs` with no answer — callers +// treat "" as "nothing to insert", so a missing or dead bridge degrades to a +// no-op rather than a hang. + +const BRIDGE_TIMEOUT_MS = 1500 + +// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's +// native copy (and navigator.clipboard.writeText) never reaches the OS +// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the +// bridge — pastes whatever stale thing was there before, not what you copied. +// Push the copied text to the extension host, which writes vscode.env.clipboard +// so read and write hit the same clipboard. Fire-and-forget: the host write is +// near-instant and callers have nothing to await. Resolves false (no-op) when +// unframed — native copy already works there. +export function writeClipboardViaBridge(text: string, win: Window = window): boolean { + if (win.parent === win) return false + win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*") + return true +} + +// The bridge only answers inside the AMICODE webview — its parent runs the +// relay. Every OTHER framed host (VS Code Simple Browser pointed at :3002, any +// plain iframe embed) posts into the void, times out, and — because the ⌘V +// handlers preventDefault before asking — pastes NOTHING, silently. So an +// empty/timed-out bridge reply falls through to navigator.clipboard.readText(): +// dead in the VS Code webview (no permission to lose), but it restores paste in +// every framed host that grants clipboard-read. API-key entry was the reported +// casualty (credential fields are exactly what you paste into an embedded app). +async function readTextDirect(win: Window): Promise { + try { + const text = await win.navigator?.clipboard?.readText?.() + return typeof text === "string" ? text : "" + } catch { + return "" // no permission / no API — the caller's no-op stands + } +} + +export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + // Unframed: native paste works — don't post into the void or wait out the timeout. + if (win.parent === win) { + resolve("") + return + } + + const nonce = Math.random().toString(36).slice(2) + let timer: ReturnType | undefined + + const finish = (text: string) => { + win.removeEventListener("message", onMessage) + if (timer !== undefined) clearTimeout(timer) + if (text) { + resolve(text) + return + } + void readTextDirect(win).then(resolve) + } + + const onMessage = (event: MessageEvent) => { + const data = event.data as { source?: string; kind?: string; nonce?: string; text?: string } | undefined + if (data?.source !== "amicode" || data.kind !== "clipboard" || data.nonce !== nonce) return + finish(typeof data.text === "string" ? data.text : "") + } + + win.addEventListener("message", onMessage) + win.parent.postMessage({ source: "amicode", kind: "clipboard-request", nonce }, "*") + timer = setTimeout(() => finish(""), timeoutMs) + }) +} + +// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host +// reads via vscode.env.clipboard — text-only, no image API), an image can only +// come from the OUTER webview reading navigator.clipboard.read() client-side. +// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the +// first image/* clipboard item, encodes it as a data URL, and replies. We +// rebuild a File from the reply so callers reuse the normal attachment path. +// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`. +export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + if (win.parent === win) { + resolve(null) + return + } + + const nonce = Math.random().toString(36).slice(2) + let timer: ReturnType | undefined + + const finish = (file: File | null) => { + win.removeEventListener("message", onMessage) + if (timer !== undefined) clearTimeout(timer) + resolve(file) + } + + const onMessage = (event: MessageEvent) => { + const data = event.data as + | { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string } + | undefined + if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return + finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename)) + } + + win.addEventListener("message", onMessage) + win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*") + timer = setTimeout(() => finish(null), timeoutMs) + }) +} + +function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null { + if (!dataUrl || !mime) return null + const comma = dataUrl.indexOf(",") + if (comma === -1) return null + try { + const binary = atob(dataUrl.slice(comma + 1)) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new File([bytes], filename || "pasted-image.png", { type: mime }) + } catch { + return null + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/placeholder.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/placeholder.ts new file mode 100644 index 00000000..d4ffda06 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/placeholder.ts @@ -0,0 +1,21 @@ +type PromptPlaceholderInput = { + mode: "normal" | "shell" + commentCount: number + example: string + suggest: boolean + t: (key: string, params?: Record) => string +} + +export function promptPlaceholder(input: PromptPlaceholderInput) { + if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example }) + if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") + if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + if (!input.suggest) return input.t("prompt.placeholder.simple") + return input.t("prompt.placeholder.normal", { example: input.example }) +} + +export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) { + if (mode === "shell") return placeholder + // amicode: brand the composer prompt + return "Ask Amico anything, / for commands, @ for context..." +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx new file mode 100644 index 00000000..fae3ded5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx @@ -0,0 +1,595 @@ +import { + createEffect, + createSignal, + For, + onCleanup, + Show, + splitProps, + type Accessor, + type ComponentProps, +} from "solid-js" +import { createStore } from "solid-js/store" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { getProjectAvatarVariant } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" +import { inAmicode } from "@/pages/session/use-amicode-commands" +import { pathKey } from "@/utils/path-key" +import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" + +export type PromptProject = { + name?: string + id?: string + worktree: string + sandboxes?: string[] + icon?: { color?: string; url?: string; override?: string } + server?: { key: string; name: string } +} + +export type PromptProjectControls = { + available: PromptProject[] + directory: string + server?: string + select: (worktree: string, server?: string) => void + add: (title: string, server?: string) => void +} + +const actionPrefix = "action:" +const projectPrefix = "project:" + +function projectKey(project: PromptProject) { + return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}` +} + +function actionKey(server?: string) { + return `${actionPrefix}${encodeURIComponent(server ?? "")}` +} + +export function createPromptProjectController(input: { + controls: Accessor + onDone: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + + const current = () => { + const key = pathKey(input.controls().directory) + return input + .controls() + .available.find( + (project) => + (!project.server || project.server.key === input.controls().server) && + (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), + ) + } + const selected = () => current() ?? input.controls().available[0] + const projects = () => { + const search = store.search.trim().toLowerCase() + if (!search) return input.controls().available + return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search)) + } + const servers = () => + input + .controls() + .available.map((project) => project.server) + .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) + const keys = () => { + if (servers().length <= 1) { + return [...projects().map(projectKey), actionKey(servers()[0]?.key)] + } + return [ + ...servers().flatMap((server) => + projects() + .filter((project) => project.server?.key === server!.key) + .map(projectKey), + ), + actionKey(), + ] + } + const initialActive = () => { + const selectedKey = selected() ? projectKey(selected()!) : undefined + const options = keys() + if (selectedKey && options.includes(selectedKey)) return selectedKey + return options[0] ?? "" + } + const close = () => { + setStore({ open: false, search: "", active: "" }) + input.onDone() + } + const select = (project: PromptProject) => { + if ( + pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || + project.server?.key !== current()?.server?.key + ) { + input.controls().select(project.worktree, project.server?.key) + } + close() + } + const add = (server?: string) => { + setStore({ open: false, search: "", active: "" }) + input.controls().add(language.t("command.project.open"), server) + } + const setSearch = (value: string) => { + const search = value.trim().toLowerCase() + const first = input + .controls() + .available.find((project) => !search || displayName(project).toLowerCase().includes(search)) + setStore({ + search: value, + active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key), + }) + } + + return { + selected, + empty: () => input.controls().available.length === 0, + projects, + servers, + projectKey, + actionKey, + open: () => store.open, + search: () => store.search, + active: () => store.active, + labels: { + add: () => language.t("session.new.project.add"), + clear: () => language.t("common.clear"), + new: () => language.t("session.new.project.new"), + search: () => language.t("session.new.project.search"), + }, + add, + select, + setOpen(open: boolean) { + if (open) { + setStore({ open: true, active: initialActive() }) + setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) + return + } + setStore({ open: false, search: "", active: "" }) + }, + setSearch, + clearSearch() { + setStore({ search: "", active: initialActive() }) + setTimeout(() => searchRef?.focus()) + }, + setActive(key: string) { + setStore("active", key) + }, + moveActive(delta: number) { + const options = keys() + if (options.length === 0) return + const index = options.indexOf(store.active) + const start = index === -1 ? 0 : index + setStore("active", options[(start + delta + options.length) % options.length]) + }, + activeProject() { + return store.active.startsWith(projectPrefix) + ? projects().find((project) => projectKey(project) === store.active) + : undefined + }, + activeServer() { + return store.active.startsWith(actionPrefix) + ? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined + : undefined + }, + activeAction() { + return store.active.startsWith(actionPrefix) + }, + setSearchRef(el: HTMLInputElement) { + searchRef = el + }, + focusSearch() { + setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) + }, + handleSearchKeydown(event: KeyboardEvent) { + return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch) + }, + } +} + +export type PromptProjectController = ReturnType + +export function PromptProjectSelector(props: { + controller: PromptProjectController + placement?: "bottom" | "bottom-start" +}) { + const [triggerReady, setTriggerReady] = createSignal(false) + let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) + let triggerFrame: number | undefined + + // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. + const setTriggerRef = (element: HTMLButtonElement) => { + const ready = () => { + if (!element.isConnected) { + triggerFrame = requestAnimationFrame(ready) + return + } + triggerFrame = undefined + setTriggerReady(true) + } + ready() + } + + onCleanup(() => { + if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame) + }) + + const activeItem = () => + props.controller.active() + ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) + : undefined + const selectProject = (project: PromptProject) => { + dismiss.preventTriggerRestore() + props.controller.setOpen(false) + dismiss.afterClose(() => props.controller.select(project)) + } + const selectAction = (server?: string) => { + dismiss.preventTriggerRestore() + props.controller.setOpen(false) + dismiss.afterClose(() => props.controller.add(server)) + } + const selectActive = () => { + const project = props.controller.activeProject() + if (project) { + selectProject(project) + return + } + if (props.controller.activeAction() && props.controller.servers().length > 1) { + const item = activeItem() + item?.focus() + item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })) + return + } + selectAction(props.controller.activeServer()) + } + const moveActive = (delta: number) => { + props.controller.moveActive(delta) + queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) + } + const focusPreviousControl = () => { + const target = Array.from( + document.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ) + .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) + .findLast((element) => element.offsetParent !== null) + dismiss.preventTriggerRestore() + target?.focus() + queueMicrotask(() => { + if (props.controller.open()) props.controller.setOpen(false) + }) + } + const selectedValue = () => { + const project = props.controller.selected() + return project ? props.controller.projectKey(project) : undefined + } + + createEffect(() => { + if (!props.controller.open()) return + const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event) + document.addEventListener("keydown", handler, true) + onCleanup(() => document.removeEventListener("keydown", handler, true)) + }) + + return ( + { + if (open) dismiss.allowTriggerRestore() + props.controller.setOpen(open) + }} + > + + + event.preventDefault()} + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} + > +
+
+ + props.controller.setSearchRef(el)} + value={props.controller.search()} + placeholder={props.controller.labels.search()} + aria-autocomplete="list" + aria-controls="prompt-project-menu" + aria-activedescendant={props.controller.active() || undefined} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => props.controller.setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Tab") { + event.preventDefault() + event.stopPropagation() + if (event.shiftKey) { + focusPreviousControl() + return + } + activeItem()?.focus() + return + } + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + props.controller.setOpen(false) + return + } + if (event.altKey || event.metaKey) return + if (event.key === "ArrowDown") { + event.preventDefault() + moveActive(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + moveActive(-1) + return + } + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + selectActive() + } + }} + /> + + + +
+
+ 1} + fallback={ + + + {(project) => ( + + )} + + + } + > + + props.controller.projects().some((project) => project.server?.key === server!.key), + )} + > + {(server) => ( +
+
+ {server!.name} +
+ + project.server?.key === server!.key)} + > + {(project) => ( + + )} + + +
+ )} +
+
+
+
+
+
+ 1} + fallback={ + + } + > + + props.controller.setActive(props.controller.actionKey())} + > + + + {props.controller.labels.add()} + + + + + + + {(server) => } + + + + + +
+ + + + ) +} + +export function PromptProjectAddButton(props: { controller: PromptProjectController }) { + return ( + + ) +} + +function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { + const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"]) + const project = () => local.controller.selected() + return ( + + ) +} + +function ProjectItem(props: { + project: PromptProject + controller: PromptProjectController + onSelect: (project: PromptProject) => void +}) { + const key = () => props.controller.projectKey(props.project) + return ( + { + props.controller.setActive(key()) + props.controller.focusSearch() + }} + onSelect={() => props.onSelect(props.project)} + > + + {displayName(props.project)} + + + + + ) +} + +const projectActionClass = + "h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover" + +function ProjectAction(props: { + server?: string + controller: PromptProjectController + onSelect: (server?: string) => void +}) { + const key = () => props.controller.actionKey(props.server) + return ( + { + props.controller.setActive(key()) + props.controller.focusSearch() + }} + onSelect={() => props.onSelect(props.server)} + > + + + {props.controller.labels.add()} + + + ) +} + +function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) { + return ( + props.onSelect(props.server.key)}> + {props.server.name} + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.css b/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.css new file mode 100644 index 00000000..2521fd3e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.css @@ -0,0 +1,49 @@ +/* amicode/opencode#116: report-a-bug — the danger-role state contract. + Color comes ONLY from the semantic danger token (red-800 light / red-500 + dark, defined in packages/ui/src/v2/styles/theme.css) — never a literal + hex, and yellow stays the sole accent. Muted at rest → full on hover, the + provider-icon opacity pattern. Every selector out-specifies (or follows) + icon-button-v2.css's variant rules so the five states hold in both themes; + forced states ([data-state]) exist so the story can render the matrix. */ + +[data-component="icon-button-v2"][data-action="report-bug"] { + border-radius: var(--radius-md); /* control radius token (design-polish.css) */ + color: var(--v2-state-fg-danger); + opacity: 0.55; + cursor: pointer; + transition: opacity 0.16s ease; +} + +[data-component="icon-button-v2"][data-action="report-bug"]:is(:hover, [data-state="hover"]):not(:disabled) { + color: var(--v2-state-fg-danger); + opacity: 1; + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="icon-button-v2"][data-action="report-bug"]:is(:active, [data-state="pressed"]):not(:disabled) { + color: var(--v2-state-fg-danger); + opacity: 1; + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +/* The visible focus ring itself comes from icon-button-v2.css + (2px var(--v2-border-border-focus)) — here the glyph just goes full-strength. */ +[data-component="icon-button-v2"][data-action="report-bug"]:is(:focus-visible, [data-state="focus"]):not( + :disabled + ) { + color: var(--v2-state-fg-danger); + opacity: 1; +} + +[data-component="icon-button-v2"][data-action="report-bug"]:is(:disabled, [data-state="disabled"]) { + /* clearly deader than the muted rest state (0.55), not just a shade off it */ + color: var(--v2-state-fg-danger); + opacity: 0.3; + cursor: not-allowed; +} + +@media (prefers-reduced-motion: reduce) { + [data-component="icon-button-v2"][data-action="report-bug"] { + transition: none; + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.stories.tsx b/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.stories.tsx new file mode 100644 index 00000000..b838e92e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.stories.tsx @@ -0,0 +1,151 @@ +import { For } from "solid-js" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { PromptInputV2SubmitButton } from "@opencode-ai/session-ui/v2/prompt-input" +import { oc2Theme, resolveThemeVariantV2 } from "@opencode-ai/ui/theme" +import { ReportBugButton } from "@/components/report-bug-button" +// The app entry loads design-polish.css globally (it owns --radius-md, which +// the button's css consumes); stories must pull it themselves. +import "@/design-polish.css" + +// amicode/opencode#116: the report-a-bug button for the v2 composer. The +// interaction-state contract (default / hover / active / focus-visible / +// disabled) is visual and lives here — the app has no component-render unit +// test surface (Solid needs its compile-time transform), per the issue's +// Testing Decisions. Gate rendering and click behavior are unit-tested in +// packages/app/src/utils/amicode-bug-report.test.ts and +// packages/app/src/pages/session/composer/report-bug.test.ts. + +/** The real oc-2 --v2-* token set for one color scheme, applied as inline + * custom properties — the SAME resolver the app runs at boot, so each pane + * re-resolves the danger token (red-800 light / red-500 dark) regardless of + * the Storybook theme toolbar. (The naive data-color-scheme wrapper is a + * no-op inside Storybook — see amico-wave.stories.tsx for the archaeology.) */ +function schemeVars(scheme: "light" | "dark"): Record { + const isDark = scheme === "dark" + const tokens = resolveThemeVariantV2(isDark ? oc2Theme.dark : oc2Theme.light, isDark) + const vars: Record = {} + for (const [key, value] of Object.entries(tokens)) vars[`--${key}`] = value + return vars +} + +const STATES: { label: string; props: { state?: "hover" | "pressed" | "focus"; disabled?: boolean } }[] = [ + { label: "default", props: {} }, + { label: "hover", props: { state: "hover" } }, + { label: "active", props: { state: "pressed" } }, + { label: "focus-visible", props: { state: "focus" } }, + { label: "disabled", props: { disabled: true } }, +] + +function StateMatrix() { + return ( +
+ + {(item) => ( +
+ + {item.label} +
+ )} +
+
+ ) +} + +/** The session-ui composer's bottom row, faithfully mirrored from + * packages/session-ui/src/v2/components/prompt-input/index.tsx: same row + * classes, same trailingControl slot wrapper (mr-1 = the 4px grid), and the + * REAL send button — the bug glyph must read as its immediate left neighbor. */ +function ComposerRowMock() { + return ( +
+
+
+ } + aria-label="Add images and files" + /> +
+ {/* the session-ui trailingControl slot renders exactly this wrapper */} +
+ +
+ {}} + onStop={() => {}} + /> +
+
+ ) +} + +export default { + title: "App/ReportBugButton", + id: "app-report-bug-button", + component: ReportBugButton, + parameters: { + docs: { + description: { + component: `### Overview +Report-a-bug button for the v2 composer's bottom control row (amicode/opencode#116). + +- Renders only when the extension passes the \`amicode_bug_report=1\` boot param — the gate lives in \`PromptInputV2Composer\`, which passes \`undefined\` to the composer's \`trailingControl\` slot when off, so the row's layout never shifts. +- Click posts \`amicode.reportBug\` over the app↔extension bridge — or reveals/re-expands the open bug dock (slice #117) via the \`bugDock\` seam, posting nothing. +- Color is the semantic danger token (\`--v2-state-fg-danger\`: red-800 light / red-500 dark) — muted at rest, full on hover. Yellow stays the only accent. + +### States +default · hover · active · focus-visible (2px \`--v2-border-border-focus\` ring) · disabled — forced via the \`state\` prop / the real \`disabled\` attribute below.`, + }, + }, + }, +} + +export const InComposerRow = { + render: () => ( +
+ + Right-anchored, immediately left of the send button — 28×28, 16px glyph, 4px from send. + + +
+ ), +} + +export const States = { + render: () => , +} + +export const Schemes = { + render: () => ( +
+ + {(scheme) => ( +
+ + {scheme} + + +
+ )} +
+
+ ), +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.tsx b/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.tsx new file mode 100644 index 00000000..ce92a01c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/report-bug-button.tsx @@ -0,0 +1,29 @@ +// amicode/opencode#116: the report-a-bug button for the v2 composer's bottom +// control row. Renders only when the amicode_bug_report=1 boot param is set — +// the CALLER (PromptInputV2Composer) owns that gate and passes `undefined` to +// the composer's trailingControl slot instead, so a gated-off button never +// shifts the row's layout. Icon-only: the aria-label + tooltip carry the +// meaning — color is never the only signal. +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2, type IconButtonV2Props } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { reportBug } from "@/pages/session/composer/report-bug" +import "./report-bug-button.css" + +export function ReportBugButton(props: { disabled?: boolean; /** stories only: force a visual state */ state?: IconButtonV2Props["state"] }) { + return ( + + } + aria-label="Report a bug" + disabled={props.disabled} + onClick={() => reportBug()} + /> + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session-context-usage.tsx b/packages/app-bundle/overlay/packages/app/src/components/session-context-usage.tsx new file mode 100644 index 00000000..a297bf22 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session-context-usage.tsx @@ -0,0 +1,161 @@ +import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js" +import { ProgressCircle } from "@opencode-ai/ui/progress-circle" +import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" +import { Button } from "@opencode-ai/ui/button" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" + +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" +import { useLanguage } from "@/context/language" +import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" +import { getSessionContext } from "@/components/session/session-context-metrics" +import { useSessionLayout } from "@/pages/session/session-layout" + +interface SessionContextUsageProps { + variant?: "button" | "indicator" + buttonAppearance?: "default" | "v2" + placement?: ComponentProps["placement"] +} + +function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +function openSessionContext(args: { + view: ReturnType["view"]> + layout: ReturnType + tabs: ReturnType["tabs"]> +}) { + args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") + if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") + void args.tabs.open("context") + args.tabs.setActive("context") +} + +export function SessionContextUsage(props: SessionContextUsageProps) { + const sync = useSync() + const layout = useLayout() + const language = useLanguage() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) + const { params, tabs, view } = useSessionLayout() + + const variant = createMemo(() => props.variant ?? "button") + const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") + + const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) + const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const cost = createMemo(() => { + return usd().format(info()?.cost ?? 0) + }) + const contextVisible = createMemo(() => view().reviewPanel.opened() && tabs().active() === "context") + const hasOtherTabs = createMemo(() => + tabs() + .all() + .some((tab) => tab !== "context" && tab !== "review"), + ) + + const openContext = () => { + if (!params.id) return + + const sessionView = view() + if (contextVisible()) { + tabs().close("context") + if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close() + return + } + + openSessionContext({ + view: sessionView, + layout, + tabs: tabs(), + }) + } + + const usageColor = createMemo(() => { + const u = context()?.usage ?? 0 + if (u >= 90) return "var(--v2-text-text-danger, #ef4444)" + if (u >= 75) return "var(--v2-text-text-warning, #f59e0b)" + return "var(--v2-icon-icon-base, var(--icon-base))" + }) + const circle = () => ( +
+ +
+ ) + const circleV2 = () => ( +
+ +
+ ) + + const tooltipValue = () => ( +
+ + + +
+ ) + + return ( + + + + {circle()} + + + + + + + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx new file mode 100644 index 00000000..8535cfac --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/context-warning-banner.tsx @@ -0,0 +1,51 @@ +import { Show, createEffect, on } from "solid-js" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { showToast } from "@/utils/toast" +import { useContextWarning } from "./use-context-warning" + +export function ContextWarningBanner() { + const language = useLanguage() + const command = useCommand() + const warning = useContextWarning() + + // toasts once per level per session + createEffect( + on( + () => warning.level(), + (level) => { + if (level === "warn" && warning.store.lastNotifiedWarn === 0) { + warning.setStore("lastNotifiedWarn", Date.now()) + showToast({ variant: "default", title: language.t("context.warning.toast.warn.title"), description: language.t("context.warning.toast.warn.description") }) + } + if (level === "critical" && warning.store.lastNotifiedCritical === 0) { + warning.setStore("lastNotifiedCritical", Date.now()) + showToast({ variant: "error", title: language.t("context.warning.toast.critical.title"), description: language.t("context.warning.toast.critical.description") }) + } + }, + ), + ) + + const compact = () => command.trigger("session.compact", "palette") + + return ( + <> + +
+ {language.t("context.warning.banner.warn", { percent: warning.usage() })} + + {language.t("command.session.compact")} + × + +
+
+ + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts new file mode 100644 index 00000000..92b19497 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts @@ -0,0 +1,8 @@ +export { SessionHeader } from "./session-header" +export { SessionContextTab } from "./session-context-tab" +export { SessionPreviewTab } from "./session-preview-tab" +export { PanelMenu } from "./panel-menu" +export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" +export { SortableTerminalTab } from "./session-sortable-terminal-tab" +export { NewSessionView } from "./session-new-view" diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/panel-menu.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/panel-menu.tsx new file mode 100644 index 00000000..f597b541 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/panel-menu.tsx @@ -0,0 +1,55 @@ +import { For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface PanelMenuItem { + id: string + label: string + icon: string + available: () => boolean + active?: () => boolean +} + +// ─── Component ────────────────────────────────────────────────────────────── + +export function PanelMenu(props: { + items: PanelMenuItem[] + onSelect: (id: string) => void + v2?: boolean +}) { + return ( + + + + + + + item.available())}> + {(item) => ( + props.onSelect(item.id)} + > + + {item.label} + + + + + )} + + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts new file mode 100644 index 00000000..0ac82300 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from "bun:test" + +/** + * Tests for the Session Chats Dropdown logic (amicode#274). + * Validates session sorting, search filtering, open/close state, and + * tab-status derivation — the same behavior as the dashboard sessions + * flyout (amicode#273). + */ + +type Session = { + id: string + title?: string + directory: string + parentID?: string + time: { created: number; updated?: number; archived?: number | null } +} + +// --- Helpers under test (pure logic extracted from the component) --- + +/** Sort sessions: open-tab sessions first, preserving relative order within each group. */ +function sortSessionsByOpenTab( + sessions: Session[], + hasOpenTab: (session: Session) => boolean, +): Session[] { + const open: Session[] = [] + const rest: Session[] = [] + for (const session of sessions) { + if (hasOpenTab(session)) { + open.push(session) + } else { + rest.push(session) + } + } + return [...open, ...rest] +} + +/** Filter sessions by search query (matches title or id). */ +function filterSessionsByQuery( + sessions: Session[], + query: string, + getTitle: (session: Session) => string, +): Session[] { + const q = query.trim().toLowerCase() + if (!q) return sessions + return sessions.filter((session) => getTitle(session).toLowerCase().includes(q)) +} + +/** + * Simulates the flyout open/close state machine — the same pattern used + * in the component. Verifies the toggle works and the dismiss handler + * (outside click / Escape) doesn't race with the opening click. + */ +function createFlyoutState() { + let open = false + let dismissListener: ((e: { target: unknown }) => void) | null = null + const flyoutRoot = { contains: (target: unknown) => target === "inside" } + + return { + get open() { return open }, + toggle() { open = !open }, + /** Simulates the deferred dismiss listener attachment (setTimeout(0)) */ + attachDismissListener() { + if (!open) { dismissListener = null; return } + dismissListener = (e) => { + if (!flyoutRoot.contains(e.target)) open = false + } + }, + /** Simulates a mousedown event after the listener is attached */ + simulateOutsideClick() { dismissListener?.({ target: "outside" }) }, + simulateInsideClick() { dismissListener?.({ target: "inside" }) }, + } +} + +// --- Tests --- + +describe("Session Chats Dropdown", () => { + const sessions: Session[] = [ + { id: "ses_1", title: "X gate optimization", directory: "/proj", time: { created: 100 } }, + { id: "ses_2", title: "CZ gate", directory: "/proj", time: { created: 200 } }, + { id: "ses_3", title: "Cat state prep", directory: "/proj", time: { created: 300 } }, + ] + + describe("open/close state", () => { + test("toggle opens the flyout", () => { + const state = createFlyoutState() + expect(state.open).toBe(false) + state.toggle() + expect(state.open).toBe(true) + }) + + test("toggle closes when already open", () => { + const state = createFlyoutState() + state.toggle() // open + state.toggle() // close + expect(state.open).toBe(false) + }) + + test("dismiss listener does NOT fire before attachment (deferred)", () => { + const state = createFlyoutState() + state.toggle() // open + // Before attachDismissListener, an outside click should NOT close + state.simulateOutsideClick() + expect(state.open).toBe(true) + }) + + test("dismiss listener closes on outside click after attachment", () => { + const state = createFlyoutState() + state.toggle() // open + state.attachDismissListener() // simulates the setTimeout(0) firing + state.simulateOutsideClick() + expect(state.open).toBe(false) + }) + + test("dismiss listener does NOT close on inside click", () => { + const state = createFlyoutState() + state.toggle() // open + state.attachDismissListener() + state.simulateInsideClick() + expect(state.open).toBe(true) + }) + }) + + describe("sortSessionsByOpenTab", () => { + test("moves sessions with open tabs to the front", () => { + const openIds = new Set(["ses_2"]) + const sorted = sortSessionsByOpenTab(sessions, (s) => openIds.has(s.id)) + + expect(sorted[0].id).toBe("ses_2") + expect(sorted.slice(1).map((s) => s.id)).toEqual(["ses_1", "ses_3"]) + }) + + test("preserves order when no sessions have open tabs", () => { + const sorted = sortSessionsByOpenTab(sessions, () => false) + expect(sorted.map((s) => s.id)).toEqual(["ses_1", "ses_2", "ses_3"]) + }) + + test("preserves order when all sessions have open tabs", () => { + const sorted = sortSessionsByOpenTab(sessions, () => true) + expect(sorted.map((s) => s.id)).toEqual(["ses_1", "ses_2", "ses_3"]) + }) + }) + + describe("filterSessionsByQuery", () => { + const getTitle = (s: Session) => s.title || s.id + + test("returns all sessions when query is empty", () => { + const result = filterSessionsByQuery(sessions, "", getTitle) + expect(result).toHaveLength(3) + }) + + test("filters by title substring (case-insensitive)", () => { + const result = filterSessionsByQuery(sessions, "gate", getTitle) + expect(result.map((s) => s.id)).toEqual(["ses_1", "ses_2"]) + }) + + test("filters by session id when title is missing", () => { + const noTitleSessions: Session[] = [ + { id: "ses_abc", directory: "/proj", time: { created: 100 } }, + { id: "ses_xyz", directory: "/proj", time: { created: 200 } }, + ] + const result = filterSessionsByQuery(noTitleSessions, "abc", getTitle) + expect(result).toHaveLength(1) + expect(result[0].id).toBe("ses_abc") + }) + + test("returns empty when nothing matches", () => { + const result = filterSessionsByQuery(sessions, "nonexistent", getTitle) + expect(result).toHaveLength(0) + }) + + test("trims whitespace from query", () => { + const result = filterSessionsByQuery(sessions, " cat ", getTitle) + expect(result).toHaveLength(1) + expect(result[0].id).toBe("ses_3") + }) + }) + + describe("openSession navigation strategy", () => { + /** + * Opening a session must use direct navigate() — NOT tabs.openPath/addSessionTab. + * tabs.addSessionTab wraps in startTransition, which keeps both old and new UI + * mounted during the transition, causing Portal-rendered buttons to duplicate. + * + * The correct strategy: + * - Session with existing tab → select the tab (no navigation, no transition) + * - Session without a tab → navigate directly (the session page creates its own tab) + * - In both cases, close the flyout BEFORE any navigation + */ + type NavigationAction = + | { type: "select-tab"; sessionId: string } + | { type: "navigate"; path: string } + + function resolveOpenAction( + session: Session, + hasExistingTab: boolean, + encodePath: (dir: string, id: string) => string, + ): NavigationAction { + if (hasExistingTab) { + return { type: "select-tab", sessionId: session.id } + } + return { type: "navigate", path: encodePath(session.directory, session.id) } + } + + const encodePath = (dir: string, id: string) => `/${btoa(dir)}/session/${id}` + + test("session with existing tab → select-tab (no startTransition)", () => { + const session = sessions[0] + const action = resolveOpenAction(session, true, encodePath) + + expect(action.type).toBe("select-tab") + expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_1") + }) + + test("session without existing tab → navigate directly (avoids startTransition)", () => { + const session = sessions[0] + const action = resolveOpenAction(session, false, encodePath) + + expect(action.type).toBe("navigate") + expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/proj")}/session/ses_1`) + }) + + test("flyout closes before navigation (prevents portal duplication)", () => { + const state = createFlyoutState() + state.toggle() // open + expect(state.open).toBe(true) + + // Simulate openSession: close first, then navigate + state.toggle() // close + const action = resolveOpenAction(sessions[0], false, encodePath) + + expect(state.open).toBe(false) // flyout closed BEFORE navigation + expect(action.type).toBe("navigate") // would navigate after close + }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx new file mode 100644 index 00000000..6d12fef5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx @@ -0,0 +1,611 @@ +import { createEffect, createMemo, createResource, createSignal, on, onCleanup, onMount, For, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useSync } from "@/context/sync" +import { checksum } from "@opencode-ai/core/util/encode" +import { findLast } from "@opencode-ai/core/util/array" +import { same } from "@/utils/same" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Accordion } from "@opencode-ai/ui/accordion" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { File } from "@opencode-ai/session-ui/file" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import { useLanguage } from "@/context/language" +import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" +import { useFile } from "@/context/file" +import { useSessionLayout } from "@/pages/session/session-layout" +import { amicodeGet } from "@/utils/amicode-fetch" +import { amicoBrainRef } from "@opencode-ai/ui/brain-ref" +import { + createContextTreeEngine, + contextTreeKindColor, + type ContextTreeEngine, + type ContextTreeKind, + type ContextTreeNodeInput, + type ContextTreeScheme, + type ContextTreeSelection, +} from "@opencode-ai/ui/context-tree-engine" +import { buildContextTree, vaultRefFromPath, type ContextTurn } from "@opencode-ai/ui/context-tree-data" +import { vaultPanel } from "@/context/vault-panel" +import { createOpenSessionFileTab } from "@/pages/session/helpers" +import { getSessionContext } from "./session-context-metrics" +import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" +import { createSessionContextFormatter } from "./session-context-format" + +const BREAKDOWN_COLOR: Record = { + system: "var(--syntax-info)", + user: "var(--syntax-success)", + assistant: "var(--syntax-property)", + tool: "var(--syntax-warning)", + other: "var(--syntax-comment)", +} + +/** Gate for the graph section: ≥1 committed (non-consider) brain ref in the session. */ +export function sessionHasContextItems( + messages: readonly { id: string; role?: string }[], + partsFor: (messageID: string) => readonly { type?: string; tool?: string; state?: { input?: unknown } }[], +): boolean { + for (const m of messages) { + if (m.role !== "assistant") continue + for (const p of partsFor(m.id) ?? []) { + if (p?.type !== "tool" || typeof p.tool !== "string") continue + const ref = amicoBrainRef(p.tool, (p.state?.input as Record) ?? {}) + if (ref && !ref.consider) return true + } + } + return false +} + +const LEGEND: { kind: ContextTreeKind; label: string }[] = [ + { kind: "note", label: "notes" }, + { kind: "source", label: "source" }, + { kind: "skill", label: "skills" }, + { kind: "agent", label: "agents" }, +] + +function Stat(props: { label: string; value: JSX.Element }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { + const file = createMemo(() => { + const parts = props.getParts(props.message.id) + const contents = JSON.stringify({ message: props.message, parts }, null, 2) + return { + name: `${props.message.role}-${props.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return ( + requestAnimationFrame(props.onRendered)} + /> + ) +} + +function RawMessage(props: { + message: Message + getParts: (id: string) => Part[] + onRendered: () => void + time: (value: number | undefined) => string +}) { + return ( + + + +
+
+ {props.message.role} • {props.message.id} +
+
+
{props.time(props.message.time.created)}
+ +
+
+
+
+ +
+ +
+
+
+ ) +} + +const emptyMessages: Message[] = [] +const emptyUserMessages: UserMessage[] = [] + +export function SessionContextTab() { + const sync = useSync() + const language = useLanguage() + const sdk = useSDK() + const server = useServer() + const file = useFile() + const providers = useProviders(() => sdk().directory) + const { params, tabs, view } = useSessionLayout() + + const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + + const messages = createMemo( + () => { + const id = params.id + if (!id) return emptyMessages + return (sync().data.message[id] ?? []) as Message[] + }, + emptyMessages, + { equals: same }, + ) + + const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] + + const userMessages = createMemo( + () => messages().filter((m) => m.role === "user") as UserMessage[], + emptyUserMessages, + { equals: same }, + ) + + const visibleUserMessages = createMemo( + () => { + const revert = info()?.revert?.messageID + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }, + emptyUserMessages, + { equals: same }, + ) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const formatter = createMemo(() => createSessionContextFormatter(language.intl())) + + const cost = createMemo(() => { + return usd().format(info()?.cost ?? 0) + }) + + const counts = createMemo(() => { + const all = messages() + const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) + const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) + return { + all: all.length, + user, + assistant, + } + }) + + const systemPrompt = createMemo(() => { + const msg = findLast(visibleUserMessages(), (m) => !!m.system) + const system = msg?.system + if (!system) return + const trimmed = system.trim() + if (!trimmed) return + return trimmed + }) + + const providerLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.providerLabel + }) + + const modelLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.modelLabel + }) + + const breakdown = createMemo( + on( + () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], + () => { + const c = ctx() + if (!c?.input) return [] + return estimateSessionContextBreakdown({ + messages: messages(), + parts: sync().data.part as Record, + input: c.input, + systemPrompt: systemPrompt(), + }) + }, + ), + ) + + const breakdownLabel = (key: SessionContextBreakdownKey) => { + if (key === "system") return language.t("context.breakdown.system") + if (key === "user") return language.t("context.breakdown.user") + if (key === "assistant") return language.t("context.breakdown.assistant") + if (key === "tool") return language.t("context.breakdown.tool") + return language.t("context.breakdown.other") + } + + const stats = [ + { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, + { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, + { label: "context.stats.provider", value: providerLabel }, + { label: "context.stats.model", value: modelLabel }, + { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, + { + label: "context.stats.cacheTokens", + value: () => + `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, + }, + { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, + { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, + { label: "context.stats.totalCost", value: cost }, + { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, + { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, + ] satisfies { label: string; value: () => JSX.Element }[] + + // ─── Context tree (relocated from the top panel per ADR 0004) ──────────── + // per-mount browsability from GET /amicode/vaults + const [vaultsRaw] = createResource( + () => server.current, + (conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined), + ) + const browsableMounts = createMemo | undefined>(() => { + const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined + if (!raw || !Array.isArray(raw.mounts)) return undefined + return new Map( + raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]), + ) + }) + const vaultLocked = (mount: string) => { + const map = browsableMounts() + if (!map) return false + if (!map.has(mount)) return true + return map.get(mount) === false + } + + const busy = createMemo(() => (sync().data.session_status[params.id ?? ""]?.type ?? "idle") !== "idle") + + const turns = createMemo(() => { + const byPrompt = new Map() + const out: ContextTurn[] = [] + for (const m of messages()) { + if (m.role !== "assistant") continue + const key = (m as { parentID?: string }).parentID ?? m.id + let turn = byPrompt.get(key) + if (!turn) { + turn = { id: key, refs: [], busy: false } + byPrompt.set(key, turn) + out.push(turn) + } + for (const p of getParts(m.id)) { + if (p.type !== "tool") continue + const ref = amicoBrainRef((p as { tool: string }).tool, ((p as { state?: { input?: unknown } }).state?.input ?? {}) as Record) + if (!ref || ref.consider) continue + turn.refs.push(ref) + } + if (typeof (m as { time?: { completed?: number } }).time?.completed !== "number" && busy()) turn.busy = true + } + return out.filter((t) => t.refs.length > 0) + }) + + const treeItemCount = createMemo(() => { + const seen = new Set() + for (const t of turns()) for (const r of t.refs) seen.add(r.path ?? r.label.toLowerCase()) + return seen.size + }) + + const hasTreeItems = createMemo(() => treeItemCount() > 0) + + const [engine, setEngine] = createSignal() + const currentScheme = (): ContextTreeScheme => + document.documentElement.dataset.colorScheme === "light" ? "light" : "dark" + const [scheme, setScheme] = createSignal( + typeof document === "undefined" ? "dark" : currentScheme(), + ) + + // node clicks open the real thing + const openTab = createOpenSessionFileTab({ + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + openTab: (tab) => tabs().open(tab), + pathFromTab: file.pathFromTab, + loadFile: file.load, + openReviewPanel: () => { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + }, + setActive: (tab) => tabs().setActive(tab), + }) + const onTreeSelect = (node: ContextTreeSelection) => { + if (!node.path || node.locked) return + const vaultRef = vaultRefFromPath(node.path) + if (vaultRef) { + vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel }) + return + } + openTab(file.tab(node.path)) + } + + // theme observer + const themeObserver = new MutationObserver(() => { + setScheme(currentScheme()) + engine()?.setTheme(currentScheme()) + }) + onMount(() => { + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-color-scheme"] }) + engine()?.resize() + engine()?.resume() + }) + onCleanup(() => { + themeObserver.disconnect() + engine()?.destroy() + }) + + // hover glance from tool rows in the log + const onToolHover = (e: Event) => { + const d = (e as CustomEvent).detail as { label?: string } | undefined + if (d?.label) engine()?.highlight(d.label) + } + window.addEventListener("amicode:brain-hover", onToolHover) + onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover)) + + const tree = createMemo(() => buildContextTree(turns(), { vaultLocked })) + createEffect(() => { + const brain = engine() + if (!brain) return + brain.setTree(tree()) + }) + + // keyboard navigation + const flatNodes = createMemo(() => { + const out: ContextTreeSelection[] = [] + const walk = (n: ContextTreeNodeInput) => { + if (n.kind !== "root") + out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked }) + for (const c of n.children ?? []) walk(c) + } + walk(tree()) + return out + }) + const [kbIndex, setKbIndex] = createSignal(-1) + const [announce, setAnnounce] = createSignal("") + const kbFocus = (index: number) => { + const list = flatNodes() + if (!list.length) return + const next = Math.min(Math.max(index, 0), list.length - 1) + setKbIndex(next) + const node = list[next] + engine()?.focus(node.id) + setAnnounce( + `${node.label} — ${node.kind}${ + node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : "" + }`, + ) + } + const onCanvasKeyDown = (e: KeyboardEvent) => { + const list = flatNodes() + if (!list.length) return + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault() + kbFocus(kbIndex() + 1) + return + case "ArrowLeft": + case "ArrowUp": + e.preventDefault() + kbFocus(kbIndex() - 1) + return + case "Home": + e.preventDefault() + kbFocus(0) + return + case "End": + e.preventDefault() + kbFocus(list.length - 1) + return + case "Enter": + case " ": { + if (kbIndex() < 0) return + e.preventDefault() + onTreeSelect(list[kbIndex()]) + return + } + } + } + // ─── End context tree ──────────────────────────────────────────────────── + + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view().scroll("context") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("context", next) + }) + } + + createEffect( + on( + () => messages().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > +
+ {/* Context tree graph — ADR 0004: relocated from the top panel */} + +
+
+
{language.t("amicode.contextTree.title")}
+
+ {language.t("amicode.contextTree.count", { count: treeItemCount() })} +
+
+
+ + {(item) => ( +
+ + {item.label} +
+ )} +
+
+ engine()?.fit()} + aria-label={language.t("amicode.contextTree.fit")} + /> +
+
+ + setEngine( + createContextTreeEngine(el, { + scheme: currentScheme(), + size: { width: 400, height: 192 }, + onSelect: onTreeSelect, + }), + ) + } + role="application" + aria-roledescription="context tree" + aria-label={language.t("amicode.contextTree.canvasLabel")} + tabIndex={0} + onKeyDown={onCanvasKeyDown} + onBlur={() => setKbIndex(-1)} + class="block h-full w-full focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-border-focus-base" + /> +
+
+ {announce()} +
+
+ + +
+ + {(stat) => [0])} value={stat.value()} />} + +
+ + 0}> +
+
{language.t("context.breakdown.title")}
+
+ + {(segment) => ( +
+ )} + +
+
+ + {(segment) => ( +
+
+
{breakdownLabel(segment.key)}
+
{segment.percent.toLocaleString(language.intl())}%
+
+ )} + +
+ +
+ + + + {(prompt) => ( +
+
{language.t("context.systemPrompt.title")}
+
+ +
+
+ )} +
+ +
+
{language.t("context.rawMessages.title")}
+ + + {(message) => ( + + )} + + +
+
+ + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx new file mode 100644 index 00000000..ddb0aa97 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx @@ -0,0 +1,1205 @@ +import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Keybind } from "@opencode-ai/ui/keybind" +import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +import { showToast } from "@/utils/toast" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { getFilename } from "@opencode-ai/core/util/path" +import { batch, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { createMediaQuery } from "@solid-primitives/media" +import { Portal } from "solid-js/web" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useServer, ServerConnection } from "@/context/server" +import { useSettings } from "@/context/settings" +import { useSync } from "@/context/sync" +import { sessionHasOpenTab, useTabs } from "@/context/tabs" +import { useTerminal } from "@/context/terminal" +import { focusTerminalById } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" +import { messageAgentColor } from "@/utils/agent" +import { decode64 } from "@/utils/base64" +import { fileManagerApp } from "@/utils/file-manager" +import { Persist, persisted } from "@/utils/persist" +import { sessionTitle } from "@/utils/session-title" +import { StatusPopover, StatusPopoverV2 } from "../status-popover" +import { statusTriggerVisibility } from "../status-popover-model" +import { useServerSync } from "@/context/server-sync" +import { useGlobal } from "@/context/global" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { sortedRootSessions } from "@/pages/layout/helpers" +import { useNavigate } from "@solidjs/router" +import type { Session } from "@opencode-ai/sdk/v2/client" + +// AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator +// noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is +// where a solver-health panel (server/Julia env/runs dir) belongs later. +// Un-hidden 2026-07-20 (amicode#159 test drive): the 7/7 hide targeted the +// popover's then-only content (upstream MCP/LSP operator noise). It now hosts +// the amicode-first Vaults + Connections tabs — hiding it orphaned both. +const AMICODE_HIDE_STATUS_POPOVER = false +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { reviewTooltipKeybind } from "../command-tooltip-keybind" +import { useTitlebarRightMount } from "../titlebar" + +const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +type OpenApp = (typeof OPEN_APPS)[number] +type OS = "macos" | "windows" | "linux" | "unknown" + +const MAC_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const WINDOWS_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const LINUX_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const detectOS = (platform: ReturnType): OS => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function SessionHeader() { + const layout = useLayout() + const command = useCommand() + const server = useServer() + const platform = usePlatform() + const language = useLanguage() + const settings = useSettings() + const sync = useSync() + const terminal = useTerminal() + const { params, view } = useSessionLayout() + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) + const os = createMemo(() => detectOS(platform)) + const isV2 = settings.general.newLayoutDesigns + const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns()) + const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true)) + // AMICODE (#174 AC2): unlike its siblings above, showStatus no longer gates + // the whole trigger — the popover now hosts the global Connections + Vaults + // tabs, and the setting defaults OFF, which orphaned them in every session. + // The settings row sells "server status", so that is all it scopes now: the + // health dot. Policy + rationale live in status-popover-model.ts (tested). + const statusVis = createMemo(() => + statusTriggerVisibility({ desktopV2: isDesktopV2(), showStatus: settings.general.showStatus() }), + ) + const isDesktop = createMediaQuery("(min-width: 768px)") + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + const apps = createMemo(() => { + if (os() === "macos") return MAC_APPS + if (os() === "windows") return WINDOWS_APPS + return LINUX_APPS + }) + + const fileManager = createMemo(() => fileManagerApp(os())) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const toggleTerminal = () => { + const next = !view().terminal.opened() + view().terminal.toggle() + if (!next) return + + const id = terminal.active() + if (!id) return + focusTerminalById(id) + } + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + const tint = createMemo(() => + messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent), + ) + const v2ActionsState = createMemo(() => ({ + statusDotVisible: statusVis().healthDot, + statusLabel: language.t("status.popover.trigger"), + reviewLabel: language.t("command.review.toggle"), + reviewKeybind: reviewTooltipKeybind(command), + reviewVisible: isDesktop(), + reviewOpened: view().reviewPanel.opened(), + onReviewToggle: () => view().reviewPanel.toggle(), + })) + + const selectApp = (app: OpenApp) => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp) => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = projectDirectory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = async () => { + const directory = projectDirectory() + if (!directory) return + try { + // Amicode webview: navigator.clipboard dies in the chat iframe — bridge first. + if (!writeClipboardViaBridge(directory)) await navigator.clipboard.writeText(directory) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + } catch (err: unknown) { + showRequestError(language, err) + } + } + + const [centerMount, setCenterMount] = createSignal(null) + const rightMount = useTitlebarRightMount() + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center")) + }) + + return ( + <> + + {(mount) => ( + + + + )} + + + {(mount) => ( + + + + + } + > +
+
+ + setMenu("open", open)} + > + + + + + + {language.t("session.header.openIn")} + + { + if (!OPEN_APPS.includes(value as OpenApp)) return + selectApp(value as OpenApp) + }} + > + + {(o) => ( + { + setMenu("open", false) + openDir(o.id) + }} + > +
+ +
+ {o.label} + + + +
+ )} +
+
+
+ + { + setMenu("open", false) + copyPath() + }} + > +
+ +
+ + {language.t("session.header.open.copyPath")} + +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + +
+
+ } + > + +
+ + )} + + + ) +} + +type SessionHeaderV2ActionsState = { + /** AMICODE (#174 AC2): the trigger itself always renders in a session; the + * show-status setting only drives this health-dot flag. */ + statusDotVisible: boolean + statusLabel: string + reviewLabel: string + reviewKeybind: string[] + reviewVisible: boolean + reviewOpened: boolean + onReviewToggle: () => void +} + +function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { + const language = useLanguage() + const command = useCommand() + + return ( +
+ + {/* amicode#274: Session Chats Dropdown — chat navigation from within a session */} + + + + + + + + + {props.state.reviewLabel} + 0}> + + + + } + > + } + /> + + +
+ ) +} + +// amicode#274: Session Chats Dropdown — mirrors the dashboard sessions flyout +// (amicode#273) inside the session header: tabbed Active/Archived, search, +// open-tab indicators, archive/unarchive actions, and cursor-based pagination. +const SESSION_DROPDOWN_ROW = + "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base" + +function SessionChatsDropdown() { + const tabs = useTabs() + const server = useServer() + const serverSync = useServerSync() + const globalCtx = useGlobal() + const language = useLanguage() + const navigate = useNavigate() + const { params } = useSessionLayout() + + const [open, setOpen] = createSignal(false) + const [flyoutTab, setFlyoutTab] = createSignal<"active" | "archived">("active") + const [search, setSearch] = createSignal("") + const [archivedSessions, setArchivedSessions] = createSignal([]) + const [archivedLoading, setArchivedLoading] = createSignal(false) + const [archivedCursor, setArchivedCursor] = createSignal(undefined) + const [archivedHasMore, setArchivedHasMore] = createSignal(true) + const ARCHIVED_PAGE_SIZE = 20 + + let flyoutRoot: HTMLDivElement | undefined + + const currentSessionID = createMemo(() => params.id) + + // Active sessions — only computed when the flyout is open to avoid + // triggering reactive subscriptions (serverSync().child pins the directory + // and can cascade re-renders to the parent Portal). + // Source from ALL project directories (same as dashboard) — not just the + // server's cwd, which may not be where sessions live (amicode#138). + const activeSessions = createMemo(() => { + if (!open()) return [] + try { + const conn = server.current + if (!conn) return [] + const ctx = globalCtx.ensureServerCtx(conn) + if (!ctx) return [] + const projects = ctx.projects.list() + const directories = projects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]) + const seen = new Set() + const sessions: Session[] = [] + for (const dir of directories) { + const [store] = ctx.sync.child(dir, { bootstrap: false }) + for (const session of sortedRootSessions(store, Date.now())) { + if (seen.has(session.id)) continue + seen.add(session.id) + sessions.push(session) + } + } + return sessions.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + } catch { + return [] + } + }) + + // Sort: open-tab sessions first + const sortedActiveSessions = createMemo(() => { + if (!open()) return [] + const all = activeSessions() + const openTabs: Session[] = [] + const rest: Session[] = [] + for (const session of all) { + if (sessionHasOpenTab(tabs.store, server.key, session)) { + openTabs.push(session) + } else { + rest.push(session) + } + } + return [...openTabs, ...rest] + }) + + // Search filtering + const searchQuery = createMemo(() => search().trim().toLowerCase()) + const filteredActiveSessions = createMemo(() => { + if (!open()) return [] + const q = searchQuery() + if (!q) return sortedActiveSessions() + return sortedActiveSessions().filter((session) => { + const title = sessionTitle(session.title) || session.id + return title.toLowerCase().includes(q) + }) + }) + const filteredArchivedSessions = createMemo(() => { + const q = searchQuery() + if (!q) return archivedSessions() + return archivedSessions().filter((session) => { + const title = sessionTitle(session.title) || session.id + return title.toLowerCase().includes(q) + }) + }) + + // SDK access for archived sessions + function getServerCtx() { + const conn = server.current + if (!conn) return + return globalCtx.ensureServerCtx(conn) + } + + async function loadArchivedSessions(reset = false) { + const ctx = getServerCtx() + if (!ctx) return + setArchivedLoading(true) + try { + const cursor = reset ? undefined : archivedCursor() + const result = await ctx.sdk.client.experimental.session.list( + { archived: true, limit: ARCHIVED_PAGE_SIZE, ...(cursor ? { cursor: Number(cursor) } : {}) }, + ) + const sessions: Session[] = (result.data ?? []) as Session[] + if (reset) { + setArchivedSessions(sessions) + } else { + setArchivedSessions((prev) => [...prev, ...sessions]) + } + const lastSession = sessions[sessions.length - 1] + if (sessions.length >= ARCHIVED_PAGE_SIZE && lastSession) { + setArchivedCursor(String(lastSession.time.updated ?? lastSession.time.created)) + setArchivedHasMore(true) + } else { + setArchivedCursor(undefined) + setArchivedHasMore(false) + } + } catch { + // degrade gracefully + } finally { + setArchivedLoading(false) + } + } + + async function archiveSession(session: Session) { + const ctx = getServerCtx() + if (!ctx) return + try { + await (ctx.sdk.client.session.update as Function)({ + sessionID: session.id, + directory: session.directory, + time: { archived: Date.now() }, + }) + setArchivedSessions((prev) => [session, ...prev]) + // Reload active sessions for this session's directory + await serverSync().project.loadSessions(session.directory, { limit: 64 }) + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: String(cause), + }) + } + } + + async function unarchiveSession(session: Session) { + const ctx = getServerCtx() + if (!ctx) return + try { + await (ctx.sdk.client.session.update as Function)({ + sessionID: session.id, + directory: session.directory, + time: { archived: null }, + }) + setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) + await serverSync().project.loadSessions(session.directory, { limit: 64 }) + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: String(cause), + }) + } + } + + // amicode#310: permanently delete an archived session with confirmation dialog. + const dialog = useDialog() + function deleteArchivedSession(session: Session) { + dialog.show(() => ( + { + const ctx = getServerCtx() + if (!ctx) return + try { + await (ctx.sdk.client.session.delete as Function)({ + sessionID: session.id, + directory: session.directory, + }) + setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) + } catch (cause) { + showToast({ + title: language.t("session.delete.failed.title"), + description: String(cause), + }) + } + dialog.close() + }} + onCancel={() => dialog.close()} + /> + )) + } + + async function openSession(session: Session) { + // Close flyout first so its Portal unmounts cleanly. + setOpen(false) + + // Mirror the dashboard's project setup: ensure the directory is registered and + // touched so the workspace context is warm when the session page mounts. + const conn = server.current + if (conn) { + const ctx = globalCtx.ensureServerCtx(conn) + ctx.projects.open(session.directory) + ctx.projects.touch(session.directory) + } + + // Await session sync BEFORE navigating (issue #176). When switching sessions + // within the same route (params.id change), the Page component persists and + // the timeline re-keys immediately. If data isn't fully cached yet, rows + // trickle in and the measurement burst overlaps with user interaction, causing + // the virtualizer's scroll anchoring to snap the viewport back. Awaiting here + // ensures messagesReady() is true with complete data the moment the timeline + // mounts — matching the dashboard path where the fresh route mount naturally + // gates on messagesReady(). For already-cached sessions this resolves instantly. + await serverSync().session.sync(session.id).catch(() => {}) + + // Use tabs.select for sessions that already have an open tab (no transition). + // For sessions without a tab, navigate directly — DO NOT use tabs.openPath + // which calls addSessionTab(startTransition), keeping both old and new UI + // mounted during the transition and duplicating Portal-rendered buttons. + const existingTab = tabs.store.find( + (t) => t.type === "session" && t.sessionId === session.id, + ) + if (existingTab) { + tabs.select(existingTab) + } else { + const path = `/${base64Encode(session.directory)}/session/${session.id}` + navigate(path) + } + } + + function handleNewChat() { + const dir = serverSync().data.path.directory || "" + void tabs.newDraft({ server: server.key, directory: dir }) + setOpen(false) + } + + // Dismiss on outside click or Escape + createEffect(() => { + if (!open()) return + // Defer listener attachment so the opening mousedown doesn't immediately dismiss + const timer = setTimeout(() => { + const onDown = (e: MouseEvent) => { + const target = e.target as Node + if (flyoutRoot?.contains(target)) return + if (triggerRef?.contains(target)) return + // Don't dismiss if the click landed inside a dialog (e.g. delete confirmation) + if (target instanceof Element && target.closest("[data-dialog-layer], [data-component='dialog-overlay']")) return + setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false) + } + document.addEventListener("mousedown", onDown) + document.addEventListener("keydown", onKey) + onCleanup(() => { + document.removeEventListener("mousedown", onDown) + document.removeEventListener("keydown", onKey) + }) + }, 0) + onCleanup(() => clearTimeout(timer)) + }) + + let triggerRef: HTMLButtonElement | undefined + + return ( + <> + + + +
+
+ {/* Search */} + + + {/* Tab bar — Active vs Archived */} +
+ + +
+ + } + onClick={handleNewChat} + aria-label={language.t("command.session.new")} + /> + +
+ + {/* Tab content */} +
+ + 0} + fallback={ +
+ {searchQuery() ? language.t("home.sessions.search.noResults", { query: search() }) : language.t("home.sessions.empty")} +
+ } + > +
+ + {(session) => ( + + )} + +
+
+
+ + 0} + fallback={ +
+ {language.t("common.loading")} +
+ } + > + 0} + fallback={ +
+ No archived sessions +
+ } + > +
+ + {(session) => ( + + )} + +
+ + + +
+
+
+
+
+
+
+
+ + ) +} + +function SessionDropdownRow(props: { + session: Session + isOpenTab: boolean + isCurrent: boolean + onOpen: (session: Session) => void + onArchive: (session: Session) => void +}) { + const language = useLanguage() + const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) + + return ( +
+ +
+ + } + aria-label={language.t("common.archive")} + onClick={(event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void props.onArchive(props.session) + }} + /> + +
+
+ ) +} + +function ArchivedSessionDropdownRow(props: { + session: Session + onOpen: (session: Session) => void + onUnarchive: (session: Session) => void + onDelete: (session: Session) => void +}) { + const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) + + return ( +
+ +
+ + } + aria-label="Unarchive" + onClick={(event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void props.onUnarchive(props.session) + }} + /> + + + } + aria-label="Delete permanently" + onClick={(event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void props.onDelete(props.session) + }} + /> + +
+
+ ) +} + +// amicode#310: confirmation dialog for permanently deleting an archived session. +function DialogDeleteArchivedSession(props: { + session: Session + onConfirm: () => void + onCancel: () => void +}) { + const language = useLanguage() + const name = createMemo(() => sessionTitle(props.session.title) || props.session.id) + + return ( + + + + + + + {language.t("common.cancel")} + + + {language.t("session.delete.button")} + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-new-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-new-view.tsx new file mode 100644 index 00000000..e50aaafe --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-new-view.tsx @@ -0,0 +1,127 @@ +import { Show, createMemo, createResource } from "solid-js" +import { DateTime } from "luxon" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { useLanguage } from "@/context/language" +import { usePrompt } from "@/context/prompt" +import { useServer } from "@/context/server" +import { startPrompt as startPromptWith } from "@/utils/start-prompt" +import { amicodeGet } from "@/utils/amicode-fetch" +import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher" +import { Icon } from "@opencode-ai/ui/icon" +import { AmicodeGettingStarted } from "@opencode-ai/ui/amicode-getting-started" +import { Logo, MarkDetailed } from "@opencode-ai/ui/logo" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" + +const MAIN_WORKTREE = "main" +const CREATE_WORKTREE = "create" +const ROOT_CLASS = "size-full flex flex-col" + +interface NewSessionViewProps { + worktree: string +} + +export function NewSessionView(props: NewSessionViewProps) { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() + const prompt = usePrompt() + + // amicode: starter chips — see utils/start-prompt.ts for the mechanism. + const startPrompt = (text: string) => startPromptWith(prompt, text) + + // amicode: Resume verb (spec B) — fetched once per view; renders only when a + // non-archived problem workspace exists (no empty chrome). Fetch failure → + // undefined → no Resume chip; the start screen stays stock. + const server = useServer() + const [problemsRaw] = createResource(() => amicodeGet(server.current, "/amicode/problems").catch(() => undefined)) + const resumeProblem = createMemo(() => { + const raw = problemsRaw() + if (raw === undefined) return undefined + const view = parseProblemsResponse(raw) + if (!view.ok) return undefined + const open = view.problems.filter((p) => p.status !== "archived") + if (open.length === 0) return undefined + return [...open].sort((a, b) => (b.recorded ?? "").localeCompare(a.recorded ?? ""))[0] + }) + + const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) + const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) + const current = createMemo(() => { + const selection = props.worktree + if (options().includes(selection)) return selection + return MAIN_WORKTREE + }) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const isWorktree = createMemo(() => { + const project = sync().project + if (!project) return false + return sdk().directory !== project.worktree + }) + + const label = (value: string) => { + if (value === MAIN_WORKTREE) { + if (isWorktree()) return language.t("session.new.worktree.main") + const branch = sync().data.vcs?.branch + if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) + return language.t("session.new.worktree.main") + } + + if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") + + return getFilename(value) + } + + return ( +
+
+
+
+
+ + {/* amicode: brand wordmark in the brand typeface (Racing Sans One — restored per review); + MarkDetailed is the redesign's detailed mark (kept from trunk) */} + +
+ {/* amicode: getting-started block (tagline + how-it-works + starter chips) */} + { + const name = resumeProblem()?.name + if (name) startPrompt(`Open the problem "${name}" and continue where we left off`) + }} + /> +
+
+
+ {getDirectory(projectRoot())} + {getFilename(projectRoot())} +
+
+
+ +
+ {label(current())} +
+
+ + {(project) => ( +
+
+ {language.t("session.new.lastModified")}  + + {DateTime.fromMillis(project().time.updated ?? project().time.created) + .setLocale(language.intl()) + .toRelative()} + +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx new file mode 100644 index 00000000..787df200 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx @@ -0,0 +1,428 @@ +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { SegmentedControlV2, SegmentedControlItemV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface PreviewFileEntry { + path: string + relativePath: string + basename: string + extension: ".md" + changeType: "added" | "modified" +} + +interface PreviewFileState { + mode: "preview" | "raw" + scrollPosition?: number + unsavedContent?: string +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +/** + * Convert ```math fenced code blocks (GitHub-flavored) to $$...$$ display math + * blocks that the Markdown component's KaTeX extension understands. + */ +function preprocessMarkdown(text: string): string { + return text.replace(/```math\n([\s\S]*?)```/g, (_match, body: string) => `$$\n${body.trim()}\n$$`) +} + +export function SessionPreviewTab(props: { + diffs: () => Array<{ file: string; status?: string }> + touchedFiles?: () => Array<{ file: string; status: string }> +}) { + const sdk = useSDK() + const serverSDK = useServerSDK() + const [selectedFile, setSelectedFile] = createSignal(undefined) + const [fileStates, setFileStates] = createStore>({}) + const [fileContent, setFileContent] = createSignal("") + const [loading, setLoading] = createSignal(false) + const [zoom, setZoom] = createSignal(100) + + const zoomIn = () => setZoom((z) => Math.min(z + 10, 200)) + const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + + // Derive the file list from touchedFiles (tool-edit history, persists regardless of git state) + // supplemented by diffs for any files not already covered. + const markdownFiles = createMemo((): PreviewFileEntry[] => { + const seen = new Set() + const entries: PreviewFileEntry[] = [] + + // Build a suffix→absolute map from touchedFiles so we can resolve ~/... paths from diffs + const touched = props.touchedFiles?.() ?? [] + const suffixToAbsolute = new Map() + for (const t of touched) { + // For "/Users/jj/.julia/dev/X", try progressively shorter suffixes + const parts = t.file.split("/") + for (let i = 1; i < parts.length; i++) { + suffixToAbsolute.set(parts.slice(i).join("/"), t.file) + } + } + + // Resolve ~/... to absolute using the suffix map + const resolveFile = (file: string): string => { + if (!file.startsWith("~/")) return file + const suffix = file.slice(2) // strip ~/ + const absolute = suffixToAbsolute.get(suffix) + return absolute ?? file + } + + const toEntry = (file: string, status: string): PreviewFileEntry | null => { + if (!file.endsWith(".md")) return null + // Use the resolved absolute path as the canonical key + const resolved = resolveFile(file) + if (seen.has(resolved)) return null + seen.add(resolved) + const parts = resolved.split("/") + const basename = parts[parts.length - 1] + // Show a short relative path: strip common leading segments until we hit a recognizable dir + const relativePath = resolved.replace(/^\/Users\/[^/]+\//, "") + return { + path: resolved, + relativePath, + basename, + extension: ".md" as const, + changeType: (status === "added" ? "added" : "modified") as "added" | "modified", + } + } + + // Primary: all files touched by edit tools in this session + for (const t of touched) { + const entry = toEntry(t.file, t.status) + if (entry) entries.push(entry) + } + + // Supplement: any diff files not already in touchedFiles + for (const d of props.diffs()) { + const entry = toEntry(d.file, d.status === "added" ? "added" : "modified") + if (entry) entries.push(entry) + } + + return entries + }) + + // Load file content when a file is selected + createEffect( + on(selectedFile, (path) => { + if (!path) return + setLoading(true) + + // Resolve the actual filesystem path from the display path + const fsPath = path.startsWith("~/") + ? path.replace("~", process.env.HOME ?? "") + : path + + sdk() + .client.file.read({ path: fsPath }) + .then((result) => { + const content = result.data + if (content && content.type === "text") { + setFileContent(content.content) + } + }) + .catch(() => { + setFileContent("") + }) + .finally(() => { + setLoading(false) + }) + }), + ) + + const currentMode = createMemo(() => { + const path = selectedFile() + if (!path) return "preview" + return fileStates[path]?.mode ?? "preview" + }) + + const goBack = () => { + setSelectedFile(undefined) + } + + // ─── Raw Editor Save ──────────────────────────────────────────────────── + + let saveTimer: ReturnType | undefined + const [saveStatus, setSaveStatus] = createSignal<"idle" | "saving" | "saved">("idle") + let savedTimer: ReturnType | undefined + + const saveFile = (path: string, content: string) => { + const fsPath = path.startsWith("~/") + ? path.replace("~", process.env.HOME ?? "") + : path + + const baseUrl = serverSDK().url + if (!baseUrl) return + + setSaveStatus("saving") + + // POST to the file write endpoint + fetch(new URL("/file/write", baseUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: fsPath, content }), + }) + .then(() => { + setSaveStatus("saved") + if (savedTimer) clearTimeout(savedTimer) + savedTimer = setTimeout(() => setSaveStatus("idle"), 2000) + }) + .catch(() => { + setSaveStatus("idle") + }) + } + + const debouncedSave = (path: string, content: string) => { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveFile(path, content), 1000) + } + + const immediateSave = () => { + const path = selectedFile() + if (!path) return + const content = fileStates[path]?.unsavedContent + if (content !== undefined) { + if (saveTimer) clearTimeout(saveTimer) + saveFile(path, content) + setFileStates(path, { ...fileStates[path], unsavedContent: undefined }) + } + } + + const handleRawEdit = (content: string) => { + const path = selectedFile() + if (!path) return + setFileStates(path, { ...fileStates[path], unsavedContent: content }) + setFileContent(content) + debouncedSave(path, content) + } + + onCleanup(() => { + if (saveTimer) clearTimeout(saveTimer) + if (savedTimer) clearTimeout(savedTimer) + }) + + // ─── Render ───────────────────────────────────────────────────────────── + + return ( +
+ } + > + {(path) => ( +
+ {/* Header with back button, mode toggle */} +
+ +
+ {markdownFiles().find((f) => f.path === path())?.basename ?? path()} +
+ + + {saveStatus() === "saving" ? "Saving..." : "Saved"} + + + {/* Zoom control: [100% | - +] */} +
+ { + const val = parseInt(e.currentTarget.value) + if (!isNaN(val) && val >= 50 && val <= 200) setZoom(val) + }} + onBlur={(e) => { + e.currentTarget.value = `${zoom()}%` + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur() + } + }} + /> +
+ + +
+
+ {/* Mode toggle */} + { + if (value !== "preview" && value !== "raw") return + const p = selectedFile() + if (p) setFileStates(p, { ...fileStates[p], mode: value }) + }} + class="!w-auto" + aria-label="View mode" + > + + + + + + + + + + + +
+ + {/* Content area */} +
+ Loading...
}> + + } + > +
+ +
+
+ +
+
+ )} + +
+ ) +} + +// ─── File List ────────────────────────────────────────────────────────────── + +function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: string) => void }) { + const copyToClipboard = (text: string) => { + if (!writeClipboardViaBridge(text)) { + void navigator.clipboard.writeText(text) + } + } + + return ( +
+ 0} + fallback={ +
+ No markdown files modified in this session +
+ } + > +
+ + {(file) => ( + + props.onSelect(file.path)} + > + + {file.changeType === "added" ? "A" : "M"} + +
+
{file.basename}
+
{file.relativePath}
+
+
+ + + copyToClipboard(file.basename)}>Copy filename + { + const fullPath = file.path.startsWith("~/") + ? file.path.replace("~", process.env.HOME ?? "") + : file.path + copyToClipboard(fullPath) + }}>Copy full path + + +
+ )} +
+
+
+
+ ) +} + +// ─── Raw Editor ───────────────────────────────────────────────────────────── + +function RawEditor(props: { content: string; onEdit: (content: string) => void; onSave: () => void; zoom: number }) { + let textareaRef: HTMLTextAreaElement | undefined + + return ( +
` + + const focused = focusTerminalById("one") + + expect(focused).toBe(true) + expect(document.activeElement?.tagName).toBe("TEXTAREA") + }) + + test("falls back to terminal element focus", () => { + document.body.innerHTML = `
` + const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement + let pointerDown = false + terminal.addEventListener("pointerdown", () => { + pointerDown = true + }) + + const focused = focusTerminalById("two") + + expect(focused).toBe(true) + expect(document.activeElement).toBe(terminal) + expect(pointerDown).toBe(true) + }) +}) + +describe("getTabReorderIndex", () => { + test("returns target index for valid drag reorder", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) + }) + + test("returns undefined for unknown droppable id", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() + }) +}) + +describe("createSessionTabs", () => { + test("normalizes the effective file tab", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["file://src/a.ts", "context"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab), + }) + + expect(result.activeTab()).toBe("norm:src/a.ts") + expect(result.activeFileTab()).toBe("norm:src/a.ts") + expect(result.closableTab()).toBe("norm:src/a.ts") + dispose() + }) + }) + + test("prefers context and review fallbacks when no file tab is active", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["context"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("context") + expect(result.closableTab()).toBe("context") + dispose() + }) + + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: [], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("review") + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBeUndefined() + dispose() + }) + }) + + test("exposes the Open File tab without treating it as a file tab", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: SESSION_OPEN_FILE_TAB as string | undefined, + all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => tab, + fileBrowser: () => true, + }) + + expect(result.openFileOpen()).toBe(true) + expect(result.panelTabs()).toEqual(["file://src/a.ts", SESSION_OPEN_FILE_TAB]) + expect(result.openedTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe(SESSION_OPEN_FILE_TAB) + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe(SESSION_OPEN_FILE_TAB) + dispose() + }) + }) + + test("hides the Open File placeholder when the file browser is unavailable", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: SESSION_OPEN_FILE_TAB as string | undefined, + all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => tab, + fileBrowser: () => false, + }) + + expect(result.openFileOpen()).toBe(false) + expect(result.panelTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe("file://src/a.ts") + dispose() + }) + }) + + test("close context then re-open: activeTab returns context", () => { + // Step 1: context is open and active + const initial = { tabs: { all: ["context"], active: "context" as string | undefined }, preview: undefined } + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => initial.tabs.active, all: () => initial.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + expect(result.activeTab()).toBe("context") + expect(result.contextOpen()).toBe(true) + dispose() + }) + + // Step 2: close context — active falls to review + const afterClose = closeSessionTab(initial, "context") + expect(afterClose.tabs.all).toEqual([]) + expect(afterClose.tabs.active).toBeUndefined() + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterClose.tabs.active, all: () => afterClose.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + // Context is gone — activeTab falls to "review" via the hasReview fallback + expect(result.contextOpen()).toBe(false) + expect(result.activeTab()).toBe("review") + dispose() + }) + + // Step 3: re-open context — simulates openSessionContext calling tabs.open("context") + const afterReopen = openSessionTab( + { tabs: afterClose.tabs, preview: afterClose.preview }, + "context", + ) + expect(afterReopen.tabs.all).toContain("context") + expect(afterReopen.tabs.active).toBe("context") + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterReopen.tabs.active, all: () => afterReopen.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + // After re-open: activeTab MUST be "context" + expect(result.contextOpen()).toBe(true) + expect(result.activeTab()).toBe("context") + dispose() + }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts new file mode 100644 index 00000000..adcfc86d --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts @@ -0,0 +1,224 @@ +import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { same } from "@/utils/same" +import { SESSION_OPEN_FILE_TAB, SESSION_PREVIEW_TAB } from "@/context/layout-tabs" + +export { SESSION_OPEN_FILE_TAB, SESSION_PREVIEW_TAB } from "@/context/layout-tabs" + +const emptyTabs: string[] = [] + +type Tabs = { + active: Accessor + all: Accessor +} + +type TabsInput = { + tabs: Accessor + pathFromTab: (tab: string) => string | undefined + normalizeTab: (tab: string) => string + review?: Accessor + hasReview?: Accessor + /** the Vault tab (amicode) — a named surface like "context", never a file */ + vaultOpen?: Accessor + fileBrowser?: Accessor +} + +export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` + +export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) { + return input.opened && input.visible +} + +export const createSessionTabs = (input: TabsInput) => { + const review = input.review ?? (() => false) + const hasReview = input.hasReview ?? (() => false) + const vaultOpen = input.vaultOpen ?? (() => false) + const fileBrowser = input.fileBrowser ?? (() => false) + const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) + const previewOpen = createMemo( + () => input.tabs().active() === SESSION_PREVIEW_TAB || input.tabs().all().includes(SESSION_PREVIEW_TAB), + ) + const openFileOpen = createMemo( + () => + fileBrowser() && + (input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)), + ) + const pulseInspectorOpen = createMemo(() => input.tabs().active() === "pulseInspector" || input.tabs().all().includes("pulseInspector")) + const panelTabs = createMemo( + () => { + const seen = new Set() + return input + .tabs() + .all() + .flatMap((tab) => { + if (tab === "context" || tab === "review" || tab === "vault" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector") return [] + if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] + const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab + if (seen.has(value)) return [] + seen.add(value) + return [value] + }) + }, + emptyTabs, + { equals: same }, + ) + const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, { + equals: same, + }) + const activeTab = createMemo(() => { + const active = input.tabs().active() + if (active === "context") return active + if (active === "pulseInspector") return active + if (active === SESSION_PREVIEW_TAB && previewOpen()) return active + if (active === "vault" && vaultOpen()) return active + if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active + if (active === "review" && review()) return active + if (active && input.pathFromTab(active)) return input.normalizeTab(active) + + const first = openedTabs()[0] + if (first) return first + if (vaultOpen()) return "vault" + if (previewOpen()) return SESSION_PREVIEW_TAB + if (contextOpen()) return "context" + if (pulseInspectorOpen()) return "pulseInspector" + if (review() && hasReview()) return "review" + return "empty" + }) + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + const closableTab = createMemo(() => { + const active = activeTab() + if (active === "context") return active + if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active + if (!openedTabs().includes(active)) return + return active + }) + + return { + contextOpen, + previewOpen, + pulseInspectorOpen, + openFileOpen, + panelTabs, + openedTabs, + activeTab, + activeFileTab, + closableTab, + } +} + +export const focusTerminalById = (id: string) => { + const wrapper = document.getElementById(`terminal-wrapper-${id}`) + const terminal = wrapper?.querySelector('[data-component="terminal"]') + if (!(terminal instanceof HTMLElement)) return false + + const textarea = terminal.querySelector("textarea") + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus() + return true + } + + terminal.focus() + terminal.dispatchEvent( + typeof PointerEvent === "function" + ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true }) + : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }), + ) + return true +} + +export const createOpenReviewFile = (input: { + showAllFiles: () => void + tabForPath: (path: string) => string + openTab: (tab: string) => void + setActive: (tab: string) => void + loadFile: (path: string) => any | Promise +}) => { + return (path: string) => { + batch(() => { + input.showAllFiles() + const maybePromise = input.loadFile(path) + const open = () => { + const tab = input.tabForPath(path) + input.openTab(tab) + input.setActive(tab) + } + if (maybePromise instanceof Promise) void maybePromise.then(open) + else open() + }) + } +} + +export const createOpenSessionFileTab = (input: { + normalizeTab: (tab: string) => string + openTab: (tab: string) => void + pathFromTab: (tab: string) => string | undefined + loadFile: (path: string) => void + openReviewPanel: () => void + setActive: (tab: string) => void +}) => { + return (value: string) => { + const next = input.normalizeTab(value) + input.openTab(next) + + const path = input.pathFromTab(next) + if (!path) return + + input.loadFile(path) + input.openReviewPanel() + input.setActive(next) + } +} + +export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => { + const fromIndex = tabs.indexOf(from) + const toIndex = tabs.indexOf(to) + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined + return toIndex +} + +export const createSizing = () => { + const [state, setState] = createStore({ active: false }) + let t: number | undefined + + const stop = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", false) + } + + const start = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", true) + } + + onMount(() => { + makeEventListener(window, "pointerup", stop) + makeEventListener(window, "pointercancel", stop) + makeEventListener(window, "blur", stop) + }) + + onCleanup(() => { + if (t !== undefined) clearTimeout(t) + }) + + return { + active: () => state.active, + start, + touch() { + start() + t = window.setTimeout(stop, 120) + }, + } +} + +export type Sizing = ReturnType diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-panel-width.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/session-panel-width.test.ts new file mode 100644 index 00000000..dca5a86c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-panel-width.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { + clampSessionPanelWidth, + clampWorkColumnWidth, + REVIEW_PANE_WIDTH_MIN, + REVIEW_PANE_WIDTH_MIN_SPLIT, + SESSION_PANEL_WIDTH_MIN, + sessionChatTakesRemainder, + sessionPanelWidthMax, + WORK_COLUMN_WIDTH_MIN, + workColumnWidthMax, +} from "./session-panel-width" + +describe("sessionPanelWidthMax", () => { + test("reserves the unified review pane minimum", () => { + expect(sessionPanelWidthMax({ available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN) + }) + + test("reserves a larger minimum for split diffs", () => { + expect(sessionPanelWidthMax({ available: 1700, split: true })).toBe(1700 - REVIEW_PANE_WIDTH_MIN_SPLIT) + expect(REVIEW_PANE_WIDTH_MIN_SPLIT).toBeGreaterThan(REVIEW_PANE_WIDTH_MIN) + }) + + test("lets the chat panel take everything beyond the review pane minimum", () => { + // Regression: the old cap was 45% of the window, forcing the review pane + // to at least 55% of the window regardless of content. + const available = 3440 + expect(sessionPanelWidthMax({ available, split: false })).toBeGreaterThan(available * 0.45) + }) + + test("never drops below the chat panel minimum on small windows", () => { + expect(sessionPanelWidthMax({ available: 600, split: true })).toBe(SESSION_PANEL_WIDTH_MIN) + expect(sessionPanelWidthMax({ available: 0, split: false })).toBe(SESSION_PANEL_WIDTH_MIN) + }) +}) + +describe("clampSessionPanelWidth", () => { + test("keeps widths already within the limit", () => { + expect(clampSessionPanelWidth({ width: 800, available: 1700, split: false })).toBe(800) + }) + + test("forces the width down when the window shrinks", () => { + expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN) + expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: true })).toBe( + 1700 - REVIEW_PANE_WIDTH_MIN_SPLIT, + ) + }) + + test("holds the chat panel minimum when there is no room for both", () => { + expect(clampSessionPanelWidth({ width: 1600, available: 700, split: true })).toBe(SESSION_PANEL_WIDTH_MIN) + }) + + test("skips clamping before the layout is measured", () => { + expect(clampSessionPanelWidth({ width: 1600, available: undefined, split: false })).toBe(1600) + }) +}) + +describe("work column width (amicode#105 — the column is bounded, the CHAT is the remainder)", () => { + // The pre-fix philosophy (this file's header): the review pane took whatever + // the chat left behind — a wide monitor squished the chat into the margin. + // The work column now owns a bounded width (default 320, user-resizable). + test("workColumnWidthMax caps the column at 60% of the row", () => { + expect(workColumnWidthMax(1000)).toBe(600) + }) + + test("workColumnWidthMax never drops below the floor", () => { + expect(workColumnWidthMax(300)).toBe(WORK_COLUMN_WIDTH_MIN) + }) + + test("clampWorkColumnWidth caps and floors, passes through pre-measure", () => { + expect(clampWorkColumnWidth({ width: 1500, available: 1000 })).toBe(600) + expect(clampWorkColumnWidth({ width: 100, available: 1000 })).toBe(WORK_COLUMN_WIDTH_MIN) + expect(clampWorkColumnWidth({ width: 1500, available: undefined })).toBe(1500) + }) + + test("the chat is the flex remainder only in v2 with the column visible", () => { + expect(sessionChatTakesRemainder({ newDesign: true, columnVisible: true })).toBe(true) + expect(sessionChatTakesRemainder({ newDesign: true, columnVisible: false })).toBe(false) + expect(sessionChatTakesRemainder({ newDesign: false, columnVisible: true })).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-panel-width.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/session-panel-width.ts new file mode 100644 index 00000000..0c028e40 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-panel-width.ts @@ -0,0 +1,44 @@ +// The review pane has no width of its own: it takes whatever the chat panel +// leaves behind. Instead of capping the chat panel at a fraction of the window +// (which forces the review pane to grow with the monitor), reserve a fixed +// minimum for the review pane and let the chat panel take everything else. +export const SESSION_PANEL_WIDTH_MIN = 450 +export const REVIEW_PANE_WIDTH_MIN = 480 +export const REVIEW_PANE_WIDTH_MIN_SPLIT = 800 + +export function sessionPanelWidthMax(input: { available: number; split: boolean }) { + const pane = input.split ? REVIEW_PANE_WIDTH_MIN_SPLIT : REVIEW_PANE_WIDTH_MIN + return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - pane) +} + +// `available` is undefined until the layout row is first measured; render the +// stored width untouched until then to avoid a first-frame snap. +export function clampSessionPanelWidth(input: { width: number; available: number | undefined; split: boolean }) { + if (input.available === undefined) return input.width + return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split })) +} + +// amicode#105: the Work Column owns a bounded width of its own. The pre-fix +// philosophy (above) let the review pane take everything the chat left behind +// — a wide monitor squished the chat into the left margin. Now the column is +// fixed-width (default 320 — DEFAULT_PANEL_COLUMN_WIDTH — user-resizable +// within these bounds) and the CHAT is the flex remainder. +export const WORK_COLUMN_WIDTH_MIN = 330 + +/** The column may never take more than 60% of the measured row. */ +export function workColumnWidthMax(available: number) { + return Math.max(WORK_COLUMN_WIDTH_MIN, Math.floor(available * 0.6)) +} + +/** `available` is undefined until the layout row is first measured; render the + * stored width untouched until then (same first-frame rule as the chat). */ +export function clampWorkColumnWidth(input: { width: number; available: number | undefined }) { + if (input.available === undefined) return input.width + return Math.min(Math.max(input.width, WORK_COLUMN_WIDTH_MIN), workColumnWidthMax(input.available)) +} + +/** In v2 with the work column visible, the chat is the flex remainder (no + * fixed pixel width). Classic layout keeps the historical fixed chat width. */ +export function sessionChatTakesRemainder(input: { newDesign: boolean; columnVisible: boolean }): boolean { + return input.newDesign && input.columnVisible +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts new file mode 100644 index 00000000..10017048 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +// amicode#105 structural ACs, locked against resurrection on the next upstream +// merge (this file is merge-hot — the ADR names these deletions deliberately): +// store_mirror_effects == 0 — no createEffect mirrors between the global +// vaultPanel store and the per-session reviewPanel +// vault_sidepanel_hosts == 0 — the vault's only host is the global drawer; +// the side-panel tab is retired +// Behavioral consequence: the sidebar-right toggle's store has a single writer, +// so its pressed state cannot disagree with the screen. +const source = readFileSync(join(import.meta.dir, "session-side-panel.tsx"), "utf8") + +describe("work column is vault-free (amicode#105)", () => { + test("no vaultPanel store references (no mirror effects, no vault tab logic)", () => { + expect(source).not.toContain("vaultPanel") + }) + + test("no vault tab trigger or content", () => { + expect(source).not.toContain('value="vault"') + expect(source).not.toContain("vaultOpen") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx new file mode 100644 index 00000000..49c81402 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -0,0 +1,1025 @@ +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onCleanup, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { Tabs } from "@opencode-ai/ui/tabs" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Icon } from "@opencode-ai/ui/icon" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Mark } from "@opencode-ai/ui/logo" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" + +import FileTree from "@/components/file-tree" +import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" +import { SessionContextUsage } from "@/components/session-context-usage" +import { RunInspector } from "@/amicode/inspector/run-inspector" +import { useInspectorBridge } from "@/amicode/inspector/inspector-context" + +const reviewTabID = "session-side-panel-review-tab" +const reviewTabPanelID = "session-side-panel-review-tabpanel" +const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" +import { SessionContextTab, SortableTab, SortableTabV2, FileVisual, SessionPreviewTab, PanelMenu } from "@/components/session" +import type { PanelMenuItem } from "@/components/session/panel-menu" +import { OpenInAppV2 } from "@/components/session/open-in-app-v2" +import { useCommand } from "@/context/command" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { + SESSION_PREVIEW_TAB, + createOpenSessionFileTab, + createSessionTabs, + getTabReorderIndex, + shouldShowFileTree, + type Sizing, +} from "@/pages/session/helpers" +import { setSessionHandoff } from "@/pages/session/handoff" +import { useSessionLayout } from "@/pages/session/session-layout" +import { WORK_COLUMN_WIDTH_MIN } from "@/pages/session/session-panel-width" +import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" + +type PulseInspectorStage = "optimization" | "calibration" | "compilation" + +function PulseInspectorContent() { + const bridge = useInspectorBridge() + const [stage, setStage] = createSignal("optimization") + + const hasActiveRun = createMemo(() => { + const r = bridge.runs() + if (r.size === 0) return false + const activeId = bridge.activeRunId() + const state = activeId ? r.get(activeId) : r.values().next().value + return state ? !state.completion : false + }) + + return ( +
+ {/* Stage segmented control */} +
+ + + +
+ + {/* Stage content */} + +
+ +
+
+
+ ) +} + +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff +const FILE_TREE_WIDTH_MIN = 240 + +function renderDiff(value: ReviewDiff): value is RenderDiff { + return typeof value.file === "string" +} + +export function SessionSidePanel(props: { + canReview: () => boolean + diffs: () => ReviewDiff[] + diffsReady: () => boolean + empty: () => string + hasReview: () => boolean + reviewHasFocusableContent: () => boolean + reviewCount: () => number + reviewPanel: () => JSX.Element + reviewSidebarToggle?: (disabled: boolean) => JSX.Element + fileBrowserState?: SessionFileBrowserState + activeDiff?: string + focusReviewDiff: (path: string) => void + reviewSnap: boolean + size: Sizing + stacked?: boolean + touchedFiles?: () => Array<{ file: string; status: string }> +}) { + const layout = useLayout() + const settings = useSettings() + const file = useFile() + const language = useLanguage() + const command = useCommand() + const sdk = useSDK() + const { sessionKey, tabs, view, params } = useSessionLayout() + const projectDirectory = createMemo(() => sdk().directory) + + const isDesktop = createMediaQuery("(min-width: 768px)") + const shown = settings.visibility.fileTree + + // the tabs-column open state (historically the review panel's flag — the + // persisted store key stays, the review UI is gone) + const tabsOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const fileOpen = createMemo( + () => + isDesktop() && + shouldShowFileTree({ + visible: shown(), + opened: layout.fileTree.opened(), + }), + ) + // upstream's review UI rides the same persisted store key as the fork's tabs + // column (reviewPanel.opened) — one memo, two names (the vault effects below + // use tabsOpen; upstream's review machinery uses reviewOpen) + const reviewOpen = tabsOpen + const open = createMemo(() => reviewOpen() || fileOpen()) + const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) + const reviewTab = createMemo(() => isDesktop()) + const panelWidth = createMemo(() => { + if (!open()) return "0px" + // the tabs column owns its width (never flex-fills the window) and can be + // dragged much narrower — Kate 2026-07-27 + if (reviewOpen()) return `${layout.panelColumn.width()}px` + return `${fileTreeWidth()}px` + }) + const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) + + const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) + const kinds = createMemo(() => { + const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { + if (!a) return b + if (a === b) return a + return "mix" as const + } + + const out = new Map() + for (const diff of diffs()) { + const file = normalizeFileTreeV2Path(diff.file) + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" + + out.set(file, kind) + + const parts = file.split("/") + for (const [idx] of parts.slice(0, -1).entries()) { + const dir = parts.slice(0, idx + 1).join("/") + if (!dir) continue + out.set(dir, merge(out.get(dir), kind)) + } + } + return out + }) + + const empty = (msg: string) => ( +
+
+
+
{msg}
+
+
+ ) + + const nofiles = createMemo(() => { + const state = file.tree.state("") + if (!state?.loaded) return false + return file.tree.children("").length === 0 + }) + + const normalizeTab = (tab: string) => { + if (!tab.startsWith("file://")) return tab + return file.tab(tab) + } + + const openReviewPanel = () => { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + } + + const openTab = createOpenSessionFileTab({ + normalizeTab, + openTab: tabs().open, + pathFromTab: file.pathFromTab, + loadFile: file.load, + openReviewPanel, + setActive: tabs().setActive, + }) + + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: reviewTab, + hasReview: props.canReview, + fileBrowser: () => !!props.fileBrowserState, + }) + const contextOpen = tabState.contextOpen + const previewOpen = tabState.previewOpen + const openFileOpen = tabState.openFileOpen + const panelTabs = tabState.panelTabs + const openedTabs = tabState.openedTabs + const activeTab = tabState.activeTab + const activeFileTab = tabState.activeFileTab + + const fileTreeTab = () => layout.fileTree.tab() + + const setFileTreeTabValue = (value: string) => { + if (value !== "changes" && value !== "all") return + layout.fileTree.setTab(value) + } + + const showAllFiles = () => { + if (fileTreeTab() !== "changes") return + layout.fileTree.setTab("all") + } + + let fileFilter: HTMLInputElement | undefined + let tabList: HTMLDivElement | undefined + const temporaryTab = tabs().preview + const previewTab = (value: string) => { + const next = normalizeTab(value) + tabs().previewTab(next) + const path = file.pathFromTab(next) + if (path) void file.load(path) + openReviewPanel() + queueMicrotask(() => tabs().setActive(next)) + } + const activateTab = (value: string) => { + const next = normalizeTab(value) + const path = file.pathFromTab(next) + if (path) void file.load(path) + openReviewPanel() + tabs().setActive(next) + } + const browserTab = createMemo(() => { + if (!props.fileBrowserState) return undefined + const active = activeTab() + if (active && file.pathFromTab(active)) return active + return activeFileTab() + }) + // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly + // selects Review while the tab For replaces a preview trigger, which would + // otherwise dispose the sidebar and reset scroll. + const fileBrowserMounted = createMemo(() => { + if (!props.fileBrowserState) return false + return openedTabs().length > 0 || openFileOpen() || !!browserTab() + }) + const fileBrowserVisible = createMemo(() => { + const active = activeTab() + return active !== "review" && active !== "context" && active !== "empty" && active !== SESSION_PREVIEW_TAB + }) + + // Markdown files for the Preview tab — check both git diffs and tool-edit history + const hasMarkdownFiles = createMemo(() => { + if (diffs().some((d) => d.file.endsWith(".md"))) return true + const touched = props.touchedFiles?.() + if (touched?.some((t) => t.file.endsWith(".md"))) return true + return false + }) + + // Panel menu items — Files Changed lives as the "review" trigger, Context and + // Preview are the secondary tabs, and Pulse Inspector is the third requested tab. + const panelMenuItems = createMemo((): PanelMenuItem[] => [ + { + id: "context", + label: "Context", + icon: "brain", + available: () => true, + active: contextOpen, + }, + { + id: "pulseInspector", + label: "Pulse Inspector", + icon: "pulse", + available: () => true, + active: () => activeTab() === "pulseInspector", + }, + { + id: SESSION_PREVIEW_TAB, + label: "Preview", + icon: "eye", + available: () => true, + active: previewOpen, + }, + ]) + + const handlePanelMenuSelect = (id: string) => { + tabs().open(id) + tabs().setActive(id) + openReviewPanel() + } + + const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) + const [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const currentTabs = tabs().all() + const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) + if (toIndex === undefined) return + tabs().move(draggable.id.toString(), toIndex) + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } + + createEffect(() => { + if (!file.ready()) return + + setSessionHandoff(sessionKey(), { + files: tabs() + .all() + .reduce>((acc, tab) => { + const path = file.pathFromTab(tab) + if (!path) return acc + + const selected = file.selectedLines(path) + acc[path] = + selected && typeof selected === "object" && "start" in selected && "end" in selected + ? (selected as SelectedLineRange) + : null + + return acc + }, {}), + }) + }) + + return ( + +
+
+ + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts new file mode 100644 index 00000000..29dcebfd --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, test } from "bun:test" +import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" + +/** + * Mirrors the logic inside MessageTimeline's `visiblePromptBubble` memo (amicode#271). + * + * Uses the viewport's startIndex: a user message is "above the viewport" when + * its row index is less than startIndex. The memo is triggered by scrollTop + * (a signal), and reads virtualizer.range.startIndex for the actual check. + */ +function findVisiblePromptBubble( + userMessages: UserMessage[], + partsFor: (id: string) => Part[], + rowIndexFor: (id: string) => number | undefined, + viewportStartIndex: number | undefined, +): { text: string; messageId: string } | undefined { + if (viewportStartIndex === undefined) return undefined + for (let i = userMessages.length - 1; i >= 0; i--) { + const msg = userMessages[i] + const rowIndex = rowIndexFor(msg.id) + if (rowIndex === undefined) continue + if (rowIndex < viewportStartIndex) { + const parts = partsFor(msg.id) + const textPart = parts.find( + (p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic, + ) + const text = textPart?.text?.trim() + if (text) return { text, messageId: msg.id } + // No text in this message (e.g. skill-only) — keep looking + continue + } + } + return undefined +} + +const userMsg = (id: string) => ({ id, role: "user" }) as UserMessage + +const textPart = (text: string, synthetic = false): TextPart => + ({ + id: `prt_${text.slice(0, 4)}`, + sessionID: "ses_1", + messageID: "msg_1", + type: "text", + text, + synthetic, + }) as TextPart + +describe("last-prompt bubble", () => { + test("shows the most recent user message above the viewport start", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [textPart("first prompt")], + msg_2: [textPart("second prompt")], + msg_3: [textPart("third prompt")], + } + const rowIndices: Record = { msg_1: 0, msg_2: 5, msg_3: 10 } + + // Viewport starts at row 7 → msg_1 (row 0) and msg_2 (row 5) are above + // Most recent above viewport = msg_2 + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 7, + ) + expect(result).toEqual({ text: "second prompt", messageId: "msg_2" }) + }) + + test("shows last message when viewport is past all messages", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2")] + const parts: Record = { + msg_1: [textPart("first")], + msg_2: [textPart("second")], + } + const rowIndices: Record = { msg_1: 0, msg_2: 3 } + + // Viewport starts at row 20 → both messages above + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 20, + ) + expect(result).toEqual({ text: "second", messageId: "msg_2" }) + }) + + test("returns undefined when all messages are still visible", () => { + const messages = [userMsg("msg_1")] + const parts: Record = { msg_1: [textPart("prompt")] } + const rowIndices: Record = { msg_1: 5 } + + // Viewport starts at row 0 → message at row 5 is still in/below view + expect( + findVisiblePromptBubble(messages, (id) => parts[id] ?? [], (id) => rowIndices[id], 0), + ).toBeUndefined() + }) + + test("returns undefined when range is undefined (not mounted)", () => { + const messages = [userMsg("msg_1")] + expect( + findVisiblePromptBubble(messages, () => [], () => 0, undefined), + ).toBeUndefined() + }) + + test("returns undefined for empty messages", () => { + expect( + findVisiblePromptBubble([], () => [], () => undefined, 10), + ).toBeUndefined() + }) + + test("skips synthetic text parts", () => { + const messages = [userMsg("msg_1")] + const parts: Record = { + msg_1: [textPart("synthetic content", true), textPart("real prompt")], + } + const rowIndices: Record = { msg_1: 0 } + + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 5, + ) + expect(result).toEqual({ text: "real prompt", messageId: "msg_1" }) + }) + + test("trims whitespace from extracted text", () => { + const messages = [userMsg("msg_1")] + const parts: Record = { msg_1: [textPart(" padded text ")] } + const rowIndices: Record = { msg_1: 0 } + + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 5, + ) + expect(result?.text).toBe("padded text") + }) + + test("updates as viewport scrolls past different messages", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [textPart("alpha")], + msg_2: [textPart("beta")], + msg_3: [textPart("gamma")], + } + const rowIndices: Record = { msg_1: 0, msg_2: 10, msg_3: 20 } + const lookup = (id: string) => parts[id] ?? [] + const rowLookup = (id: string) => rowIndices[id] + + // Scrolled just past msg_1 + expect(findVisiblePromptBubble(messages, lookup, rowLookup, 3)?.text).toBe("alpha") + // Scrolled past msg_2 + expect(findVisiblePromptBubble(messages, lookup, rowLookup, 15)?.text).toBe("beta") + // Scrolled past msg_3 + expect(findVisiblePromptBubble(messages, lookup, rowLookup, 25)?.text).toBe("gamma") + }) + + test("streaming case: message goes above viewport as content grows", () => { + // The agent is streaming. Auto-scroll keeps the viewport at the bottom. + // The user's message started visible but is now above the viewport. + const messages = [userMsg("msg_1")] + const parts: Record = { msg_1: [textPart("fix the bug in auth.ts")] } + const rowIndices: Record = { msg_1: 0 } + + // Initially: viewport starts at row 0 → message at row 0 is NOT above + expect( + findVisiblePromptBubble(messages, (id) => parts[id] ?? [], (id) => rowIndices[id], 0), + ).toBeUndefined() + + // After streaming: viewport has moved to row 5 → message at row 0 IS above + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 5, + ) + expect(result).toEqual({ text: "fix the bug in auth.ts", messageId: "msg_1" }) + }) + + test("existing session opened at bottom: message far above viewport shows bubble", () => { + // Key case: open a long existing session anchored at the bottom. + // User message is at row 2, viewport starts at row 400. + const messages = [userMsg("msg_1"), userMsg("msg_2")] + const parts: Record = { + msg_1: [textPart("initial question")], + msg_2: [textPart("follow-up question")], + } + const rowIndices: Record = { msg_1: 2, msg_2: 200 } + + // Viewport at row 400 → both messages above + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 400, + ) + expect(result).toEqual({ text: "follow-up question", messageId: "msg_2" }) + }) + + test("skips skill-only messages and shows the previous message with text", () => { + // User sent a real message, then a skill-only command (e.g. /tdd). + // The bubble should show the real message, not disappear. + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [textPart("implement the auth flow")], + msg_2: [textPart("add some tests for it")], + // msg_3 is a skill invocation — has a skill part but no meaningful text + msg_3: [{ id: "prt_skill", sessionID: "ses_1", messageID: "msg_3", type: "skill", name: "tdd" } as unknown as Part], + } + const rowIndices: Record = { msg_1: 0, msg_2: 8, msg_3: 16 } + + // Viewport at row 20 → all messages above. msg_3 has no text, so falls through to msg_2 + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 20, + ) + expect(result).toEqual({ text: "add some tests for it", messageId: "msg_2" }) + }) + + test("skips multiple consecutive skill-only messages", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [textPart("the real question")], + msg_2: [], // empty parts + msg_3: [], // empty parts + } + const rowIndices: Record = { msg_1: 0, msg_2: 5, msg_3: 10 } + + const result = findVisiblePromptBubble( + messages, + (id) => parts[id] ?? [], + (id) => rowIndices[id], + 15, + ) + expect(result).toEqual({ text: "the real question", messageId: "msg_1" }) + }) +}) + +/** + * Mirrors the `findPreviousBubble` logic: when the user clicks the bubble + * showing message N, find message N-1 (the previous user message with text). + */ +function findPreviousBubble( + currentMessageId: string, + userMessages: UserMessage[], + partsFor: (id: string) => Part[], +): { text: string; messageId: string } | undefined { + const currentIdx = userMessages.findIndex((m) => m.id === currentMessageId) + if (currentIdx <= 0) return undefined + for (let i = currentIdx - 1; i >= 0; i--) { + const msg = userMessages[i] + const parts = partsFor(msg.id) + const textPart = parts.find( + (p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic, + ) + const text = textPart?.text?.trim() + if (text) return { text, messageId: msg.id } + } + return undefined +} + +describe("click-to-previous (bubble override on click)", () => { + test("clicking bubble showing msg_2 switches to msg_1", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [textPart("first question")], + msg_2: [textPart("second question")], + msg_3: [textPart("third question")], + } + + const result = findPreviousBubble("msg_3", messages, (id) => parts[id] ?? []) + expect(result).toEqual({ text: "second question", messageId: "msg_2" }) + }) + + test("clicking bubble showing the first message returns undefined (hides bubble)", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2")] + const parts: Record = { + msg_1: [textPart("only message")], + msg_2: [textPart("second")], + } + + const result = findPreviousBubble("msg_1", messages, (id) => parts[id] ?? []) + expect(result).toBeUndefined() + }) + + test("skips skill-only messages when finding previous", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [textPart("real question")], + msg_2: [], // skill-only, no text + msg_3: [textPart("latest question")], + } + + // Clicking msg_3 should skip msg_2 (no text) and land on msg_1 + const result = findPreviousBubble("msg_3", messages, (id) => parts[id] ?? []) + expect(result).toEqual({ text: "real question", messageId: "msg_1" }) + }) + + test("returns undefined when all previous messages are skill-only", () => { + const messages = [userMsg("msg_1"), userMsg("msg_2"), userMsg("msg_3")] + const parts: Record = { + msg_1: [], // no text + msg_2: [], // no text + msg_3: [textPart("latest")], + } + + const result = findPreviousBubble("msg_3", messages, (id) => parts[id] ?? []) + expect(result).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx new file mode 100644 index 00000000..8be15638 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -0,0 +1,2227 @@ +import { + createEffect, + createMemo, + createResource, + createSignal, + For, + Index, + on, + onCleanup, + onMount, + Show, + type Accessor, + type JSX, +} from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import { useNavigate, useParams } from "@solidjs/router" +import { useMutation } from "@tanstack/solid-query" +import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" +import { Accordion } from "@opencode-ai/ui/accordion" +import { AmicodeEntityRail } from "@opencode-ai/ui/amicode-entity-rail" +import { ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking" +import { + AmicodeEntityView, + entityLabel, + parseProblemResponse, + parseRunStatusResponse, +} from "@opencode-ai/ui/amicode-entity-view" +import { parseDashboardResponse, type DashboardState } from "@opencode-ai/ui/amicode-widget-grid" +import { Button } from "@opencode-ai/ui/button" +import { Card } from "@opencode-ai/ui/card" +import { + ContextToolGroup, + Message, + MessageDivider, + Part as MessagePart, + partDefaultOpen, + type UserActions, +} from "@opencode-ai/session-ui/message-part" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { Dialog } from "@opencode-ai/ui/dialog" +import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { SessionRetry } from "@opencode-ai/session-ui/session-retry" +import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { TextField } from "@opencode-ai/ui/text-field" +import { TextReveal } from "@opencode-ai/ui/text-reveal" +import type { + AssistantMessage, + Message as MessageType, + Part as PartType, + TextPart, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk/v2" +import { showToast } from "@/utils/toast" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { Popover as KobaltePopover } from "@kobalte/core/popover" +import { normalize } from "@opencode-ai/session-ui/session-diff" +import { useFileComponent } from "@opencode-ai/ui/context/file" +import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { useSessionKey } from "@/pages/session/session-layout" +import { useServerSDK } from "@/context/server-sdk" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { usePrompt } from "@/context/prompt" +import { useSettings } from "@/context/settings" +import { useTabs } from "@/context/tabs" +import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" +import { draftPrompt } from "@/utils/start-prompt" +import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands" +import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" +import { sessionTitle } from "@/utils/session-title" +import { scheduleConnectedMeasure } from "./measure" +import { observeElementOffsetReconnectAware } from "./observe-element-offset" +import { createTimelineProjection } from "./projection" +import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" +import { filterVirtualIndexes } from "./virtual-items" + +const emptyMessages: MessageType[] = [] +const emptyParts: PartType[] = [] +const emptyTools: ToolPart[] = [] +const emptyAssistantMessages: AssistantMessage[] = [] +const idle = { type: "idle" as const } + +type FramedTimelineRow = Exclude +type TimelineRowByTag = Extract + +const timelineFallbackItemSize = 60 +const timelineCache = new Map }>() + +const taskDescription = (part: PartType, sessionID: string) => { + if (part.type !== "tool" || part.tool !== "task") return + const metadata = "metadata" in part.state ? part.state.metadata : undefined + if (metadata?.sessionId !== sessionID) return + const value = part.state.input?.description + if (typeof value === "string" && value) return value +} + +const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { + const current = target instanceof Element ? target : undefined + const nested = current?.closest("[data-scrollable]") + if (!nested || nested === root) return root + if (!(nested instanceof HTMLElement)) return root + return nested +} + +const markBoundaryGesture = (input: { + root: HTMLDivElement + target: EventTarget | null + delta: number + onMarkScrollGesture: (target?: EventTarget | null) => void +}) => { + const target = boundaryTarget(input.root, input.target) + if (target === input.root) { + input.onMarkScrollGesture(input.root) + return + } + if ( + shouldMarkBoundaryGesture({ + delta: input.delta, + scrollTop: target.scrollTop, + scrollHeight: target.scrollHeight, + clientHeight: target.clientHeight, + }) + ) { + input.onMarkScrollGesture(input.root) + } +} + +function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean; tokens?: number }) { + // Simple version - just show the ThinkingLine without collapsible dropdown + return ( +
+ +
+ ) +} + +function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { + const language = useLanguage() + const maxFiles = 10 + const [state, setState] = createStore({ + showAll: false, + expanded: [] as string[], + }) + const showAll = () => state.showAll + const expanded = () => state.expanded + const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles)) + const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles))) + + return ( +
+
+ + {language.t( + props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other", + { count: String(props.diffs.length) }, + )} + + + 0}> + setState("showAll", !showAll())}> + {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")} + + +
+
+ setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} + > + + {(diff) => { + const opened = createMemo(() => expanded().includes(diff.file)) + + return ( + + + +
+ + + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + +
+ + + + + + +
+
+
+
+ + + + + +
+ ) + }} +
+
+ 0}> +
setState("showAll", true)}> + {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })} +
+
+
+
+ ) +} + +function TimelineDiffView(props: { diff: SummaryDiff }) { + const fileComponent = useFileComponent() + const view = normalize(props.diff) + + return ( +
+ +
+ ) +} + +export function MessageTimeline(props: { + actions?: UserActions + scroll: { overflow: boolean; bottom: boolean; jump: boolean } + onResumeScroll: () => void + setScrollRef: (el: HTMLDivElement | undefined) => void + onScheduleScrollState: (el: HTMLDivElement) => void + onAutoScrollHandleScroll: () => void + onMarkScrollGesture: (target?: EventTarget | null) => void + hasScrollGesture: () => boolean + onUserScroll: () => void + onHistoryScroll: () => void + onAutoScrollInteraction: (event: MouseEvent) => void + shouldAnchorBottom: () => boolean + centered: boolean + setContentRef: (el: HTMLDivElement) => void + userMessages: UserMessage[] + anchor: (id: string) => string + setRevealMessage?: (fn: (id: string) => void) => void + setScrollToEnd?: (fn: () => void) => void + setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void +}) { + let touchGesture: number | undefined + + const navigate = useNavigate() + const serverSDK = useServerSDK() + const sdk = useSDK() + const sync = useSync() + const settings = useSettings() + const tabs = useTabs() + const dialog = useDialog() + const language = useLanguage() + const command = useCommand() + const { params, sessionKey } = useSessionKey() + + // ── amicode: problem-UI wiring (ported from the pre-merge message-timeline; + // the mounts died with that file — see AMICODE-PATCHES.md "Upstream sync + // 2026-08-01"). The app owns transport (per-active-server Basic auth via + // amicodeGet) + the ring-2 entity dialog; the rail owns its own polling. + // Dialog resources are gated on the open signal, cleared on close. + const server = useServer() + const prompt = usePrompt() + const [entityViewOpen, setEntityViewOpen] = createSignal(false) + const [problemRaw, { refetch: refetchProblem }] = createResource( + () => (entityViewOpen() ? 1 : undefined), + () => amicodeGet(server.current, "/amicode/problem"), + ) + const problemView = createMemo(() => { + if (problemRaw.error) return parseProblemResponse({ ok: false, error: language.t("amicode.fetchFailed") }) + const raw = problemRaw.latest + return raw === undefined ? undefined : parseProblemResponse(raw) + }) + // Capability warrants (spec-20260727-164748 §9.5). Fetched unconditionally rather + // than gated on a dialog: the approval card lives IN the transcript, so it needs + // its state on first paint. Cheap — one ledger read, and the card's state derives + // from these rows rather than from optimistic local state, so a press round-trips + // through the ledger before it reads as granted. + const [warrantsRaw, { refetch: refetchWarrants }] = createResource(() => + amicodeGet(server.current, "/amicode/warrants").catch(() => undefined), + ) + const warrants = createMemo(() => { + const raw = warrantsRaw.latest as { ok?: boolean; warrants?: unknown } | undefined + if (!raw?.ok || !Array.isArray(raw.warrants)) return [] + // Tolerant per row: a malformed row is dropped, never defaulted into a warrant. + return raw.warrants.flatMap((r) => { + const row = r as Record + if (typeof row.plan_hash !== "string" || typeof row.expires_at !== "string") return [] + return [{ + plan_hash: row.plan_hash, + bounds: (typeof row.bounds === "object" && row.bounds !== null ? row.bounds : {}) as Record, + expires_at: row.expires_at, + issued_by: typeof row.issued_by === "string" ? row.issued_by : "unknown", + // Omitted when the server did not send it, so the rail chip drops the count + // rather than rendering a wrong "0 of N". + ...(typeof row.solves_used === "number" ? { solves_used: row.solves_used } : {}), + }] + }) + }) + // Run verdict data for the entity view: /amicode/run-status is fetched + // whenever a dialog opens (cheap, server-cached ~1s); only the Run dialog + // reads it. Same endpoint the rail's run chip polls. + const [runStatusRaw] = createResource( + () => (entityViewOpen() ? 1 : undefined), + () => amicodeGet(server.current, "/amicode/run-status"), + ) + const entityRunStatus = createMemo(() => parseRunStatusResponse(runStatusRaw.latest)) + const openEntityView = (kind: string, seq?: number) => { + setEntityViewOpen(true) + dialog.show( + () => ( + + {/* fit-to-content goes near-fullscreen on run entities (long paths + + history) — cap the panel and let it scroll internally instead */} +
+ { + dialog.close() + draftPrompt(prompt, text) + }} + onRetry={() => void refetchProblem()} + retryLabel={language.t("amicode.retry")} + editLabel={language.t("amicode.editInChat")} + /> +
+
+ ), + () => setEntityViewOpen(false), + ) + } + + const ownerSessionKey = sessionKey() + const cached = timelineCache.get(ownerSessionKey) + const initialMeasurements = cached?.measurements + const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom() + // Hide the scroll container for the first frame on cold-bottom-mount to prevent + // a flash of content at the top before scrollToEnd fires. + const [scrollReady, setScrollReady] = createSignal(!coldBottomMount) + const platform = usePlatform() + + const [listRoot, setListRoot] = createSignal() + const sessionID = createMemo(() => params.id) + const sessionStatus = createMemo(() => { + const id = sessionID() + if (!id) return idle + return sync().data.session_status[id] ?? idle + }) + const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) + const projectedMessages = createMemo(() => { + const id = sessionID() + if (!id) return [] + const visible = new Set(props.userMessages.map((message) => message.id)) + const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id + const messages = sync().data.session_message[id] ?? [] + return boundary ? messages.filter((message) => message.id < boundary) : messages + }) + const info = createMemo(() => { + const id = sessionID() + if (!id) return + return sync().session.get(id) + }) + const titleValue = createMemo(() => info()?.title) + const titleLabel = createMemo(() => sessionTitle(titleValue())) + const shareUrl = createMemo(() => info()?.share?.url) + const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") + const parentID = createMemo(() => info()?.parentID) + const parent = createMemo(() => { + const id = parentID() + if (!id) return + return sync().session.get(id) + }) + const parentMessages = createMemo(() => { + const id = parentID() + if (!id) return emptyMessages + return sync().data.message[id] ?? emptyMessages + }) + const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) + const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts + + // amicode: live token count for a turn's assistant messages — feeds the + // thinking line's token chip on the Thinking row (the recovered wave mount). + const assistantTokensForTurn = (userMessageID: string) => { + const msgs = sessionMessages() + const start = msgs.findIndex((m) => m.id === userMessageID) + if (start === -1) return 0 + return turnTokens(msgs.slice(start + 1).filter((m) => m.role === "assistant")) + } + const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) + const childTaskDescription = createMemo(() => { + const id = sessionID() + if (!id) return + return parentMessages() + .flatMap((message) => getMsgParts(message.id)) + .map((part) => taskDescription(part, id)) + .findLast((value): value is string => !!value) + }) + const childTitle = createMemo(() => { + if (!parentID()) return titleLabel() ?? "" + if (childTaskDescription()) return childTaskDescription() + const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "") + if (value) return value + return language.t("command.session.new") + }) + const showHeader = createMemo(() => !!(titleValue() || parentID())) + const projection = createTimelineProjection({ + messages: sessionMessages, + userMessages: () => props.userMessages, + sessionMessages: projectedMessages, + parts: getMsgParts, + status: sessionStatus, + showReasoningSummaries: settings.general.showReasoningSummaries, + inlineComments: settings.general.newLayoutDesigns, + }) + const activeMessageID = projection.activeMessageID + const assistantMessagesByParent = projection.assistantMessagesByParent + const lastAssistantGroupKey = projection.lastAssistantGroupKey + const messageByID = projection.messageByID + const messageLastRowIndex = projection.messageLastRowIndex + const messageRowIndex = projection.messageRowIndex + const timelineRowByKey = projection.rowByKey + const timelineRows = projection.rows + + let prependAnchor: { key: string; offset: number } | undefined + let prependAnchorFrame: number | undefined + let prependLoading = false + const clearPrependAnchor = () => { + prependLoading = false + prependAnchor = undefined + if (prependAnchorFrame === undefined) return + cancelAnimationFrame(prependAnchorFrame) + prependAnchorFrame = undefined + } + const capturePrependAnchor = () => { + prependLoading = true + updatePrependAnchor() + } + const updatePrependAnchor = () => { + const root = listRoot() + if (!root) return + const view = root.getBoundingClientRect() + const anchor = [...root.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0] + if (!anchor) return + if (!anchor.element.dataset.timelineKey) return + prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top } + } + const restorePrependAnchor = (done: boolean) => { + if (done) prependLoading = false + applyPrependAnchor() + } + const applyPrependAnchor = () => { + const root = listRoot() + if (!root || !prependAnchor) return + if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) + let frames = 0 + let stable = 0 + const apply = () => { + prependAnchorFrame = undefined + const anchor = prependAnchor + if (!anchor) return + const element = root.querySelector(`[data-timeline-key="${CSS.escape(anchor.key)}"]`) + const delta = element + ? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset + : undefined + if (delta !== undefined && Math.abs(delta) > 0.5) { + root.scrollTop += delta + stable = 0 + } else { + stable += 1 + } + frames += 1 + if (stable >= 30 || frames >= 180) { + if (!prependLoading) prependAnchor = undefined + return + } + prependAnchorFrame = requestAnimationFrame(apply) + } + prependAnchorFrame = requestAnimationFrame(apply) + } + + const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) + const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20) + let resizePinnedIndexes: number[] = [] + let resizePinFrame: number | undefined + let virtualContent: HTMLDivElement | undefined + const virtualizer = createVirtualizer({ + get count() { + return timelineRows().length + }, + getScrollElement: () => listRoot() ?? null, + observeElementOffset: observeElementOffsetReconnectAware, + initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), + initialMeasurementsCache: initialMeasurements, + estimateSize: () => timelineFallbackItemSize, + scrollToFn: (offset, options, instance) => { + // Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height. + if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px` + elementScroll(offset, options, instance) + }, + get getItemKey() { + const rows = timelineRows() + return (index: number) => { + const row = rows[index] + // ResizeObserver can report a removed element after its row has left the projection. + if (!row) return `removed:${index}` + return TimelineRow.key(row) + } + }, + anchorTo: "end", + get followOnAppend() { + return props.shouldAnchorBottom() && !props.hasScrollGesture() + }, + scrollEndThreshold: 80, + get scrollMargin() { + return showHeader() ? 64 : 0 + }, + overscan: 50, + paddingEnd: 64, + rangeExtractor: (range) => { + const id = activeMessageID() + const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1 + const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() }) + return filterVirtualIndexes( + [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b), + range.count, + ) + }, + }) + const resizeItem = virtualizer.resizeItem + let resizeAnchorScheduled = false + const anchorResizedBottom = () => { + if (resizeAnchorScheduled || props.hasScrollGesture()) return + resizeAnchorScheduled = true + queueMicrotask(() => { + resizeAnchorScheduled = false + if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return + virtualizer.scrollToEnd() + }) + } + virtualizer.resizeItem = (index, size) => { + const item = virtualizer.measurementsCache[index] + const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined + const root = listRoot() + if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) { + const view = root.getBoundingClientRect() + resizePinnedIndexes = [...root.querySelectorAll("[data-index]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => Number(element.dataset.index)) + if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + resizePinFrame = requestAnimationFrame(() => { + resizePinFrame = requestAnimationFrame(() => { + resizePinFrame = undefined + resizePinnedIndexes = [] + }) + }) + } + resizeItem(index, size) + if (root && props.shouldAnchorBottom()) anchorResizedBottom() + } + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => { + if (props.shouldAnchorBottom()) return false + const first = virtualizer.range?.startIndex + return first !== undefined && item.index < first + } + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), + ) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string)) + // amicode#271: a signal tracking the scroll container's scrollTop, updated on + // every scroll event. Drives the last-prompt bubble reactivity. + const [scrollTop, setScrollTop] = createSignal(0) + createEffect(() => { + const root = listRoot() + if (root) setScrollTop(root.scrollTop) + }) + // The bubble state: which user message text to show and link to. + // Computed from scroll position, but explicitly overridden on click. + const [bubbleOverride, setBubbleOverride] = createSignal<{ text: string; messageId: string } | undefined>(undefined) + const computeBubble = (): { text: string; messageId: string } | undefined => { + const range = virtualizer.range + if (!range) return undefined + const startIndex = range.startIndex + const messages = props.userMessages + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + const rowIndex = messageRowIndex().get(msg.id) + if (rowIndex === undefined) continue + if (rowIndex < startIndex) { + const parts = getMsgParts(msg.id) + const textPart = parts.find((p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic) + const text = textPart?.text?.trim() + if (text) return { text, messageId: msg.id } + continue + } + } + return undefined + } + // Recompute on scroll; clears any override so the bubble tracks scroll position + const visiblePromptBubble = createMemo<{ text: string; messageId: string } | undefined>(() => { + scrollTop() + return computeBubble() + }) + // What the bubble actually shows: override (from click) takes priority + const activeBubble = () => bubbleOverride() ?? visiblePromptBubble() + + // Find the previous user message with text before a given message ID + const findPreviousBubble = (currentMessageId: string): { text: string; messageId: string } | undefined => { + const messages = props.userMessages + const currentIdx = messages.findIndex((m) => m.id === currentMessageId) + if (currentIdx <= 0) return undefined + for (let i = currentIdx - 1; i >= 0; i--) { + const msg = messages[i] + const parts = getMsgParts(msg.id) + const textPart = parts.find((p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic) + const text = textPart?.text?.trim() + if (text) return { text, messageId: msg.id } + } + return undefined + } + + // Flag to suppress override clearing during programmatic scrolls from bubble click + let bubbleScrolling = false + + const scrollToBubbleMessage = () => { + const bubble = activeBubble() + if (!bubble) return + const rowIndex = messageRowIndex().get(bubble.messageId) + if (rowIndex !== undefined) { + const prev = findPreviousBubble(bubble.messageId) + setBubbleOverride(prev) + bubbleScrolling = true + virtualizer.scrollToIndex(rowIndex, { align: "center" }) + // Allow the programmatic scroll to settle before re-enabling override clearing + setTimeout(() => { bubbleScrolling = false }, 300) + } + } + createEffect(() => { + props.setRevealMessage?.((id) => { + const index = messageRowIndex().get(id) + if (index === undefined) return + virtualizer.scrollToIndex(index, { align: "center" }) + }) + props.setScrollToEnd?.(() => virtualizer.scrollToEnd()) + props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) + }) + + let overscanFrame: number | undefined + onMount(() => { + // Single scroll to end after initial render to prevent flicker + overscanFrame = requestAnimationFrame(() => { + overscanFrame = undefined + if (renderOverscan() < 20) setRenderOverscan(20) + if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() + if (!scrollReady()) setScrollReady(true) + }) + }) + + const maybeAnchorBottom = () => { + if (timelineRows().length === 0) return + if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return + if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + clearPrependAnchor() + if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) + virtualizer.scrollToEnd() + } + + let measuredSessionKey = sessionKey() + createEffect(() => { + const key = sessionKey() + timelineRows().length + if (measuredSessionKey !== key) { + measuredSessionKey = key + virtualizer.measure() + } + maybeAnchorBottom() + }) + + onCleanup(() => { + clearPrependAnchor() + timelineCache.delete(ownerSessionKey) + timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } }) + while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!) + if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame) + props.setRevealMessage?.(() => {}) + props.setScrollToEnd?.(() => {}) + props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} }) + }) + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + menuOpen: false, + pendingRename: false, + pendingShare: false, + }) + let titleRef: HTMLInputElement | undefined + + const [share, setShare] = createStore({ + open: false, + dismiss: null as "escape" | "outside" | null, + }) + let more: HTMLButtonElement | undefined + + const bindListRoot = (root: HTMLDivElement) => { + if (root === listRoot()) return + setListRoot(root) + props.setScrollRef(root) + } + + const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { + if (!prependLoading) clearPrependAnchor() + const root = event.currentTarget + const delta = normalizeWheelDelta({ + deltaY: event.deltaY, + deltaMode: event.deltaMode, + rootHeight: root.clientHeight, + }) + if (!delta) return + markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) + } + + const handleListTouchStart = (event: TouchEvent) => { + if (!prependLoading) clearPrependAnchor() + touchGesture = event.touches[0]?.clientY + } + + const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => { + const next = event.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + markBoundaryGesture({ + root: event.currentTarget, + target: event.target, + delta, + onMarkScrollGesture: props.onMarkScrollGesture, + }) + } + + const handleListTouchEnd = () => { + touchGesture = undefined + } + + const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { + if (!prependLoading) clearPrependAnchor() + props.onMarkScrollGesture(event.target) + } + + const handleListPointerMove = (event: PointerEvent) => { + if (event.buttons !== 1) return + props.onMarkScrollGesture(event.target) + } + + const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => { + const key = scrollKey(event) + if (!key) return + if (!isScrollKeyTarget(event.target, key)) return + if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return + if (!prependLoading) clearPrependAnchor() + props.onMarkScrollGesture(event.currentTarget) + } + + const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (prependLoading) updatePrependAnchor() + props.onScheduleScrollState(event.currentTarget) + props.onHistoryScroll() + // amicode#271: update scroll offset for the last-prompt bubble + setScrollTop(event.currentTarget.scrollTop) + if (!props.hasScrollGesture()) return + // User-initiated scroll — clear any click override so bubble tracks position + // (but not if we're mid-programmatic scroll from a bubble click) + if (!bubbleScrolling) setBubbleOverride(undefined) + props.onUserScroll() + props.onAutoScrollHandleScroll() + props.onMarkScrollGesture(event.currentTarget) + } + + onCleanup(() => { + props.setScrollRef(undefined) + }) + + const viewShare = () => { + const url = shareUrl() + if (!url) return + platform.openExternal(url) + } + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + const shareMutation = useMutation(() => ({ + mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }), + onError: (err) => { + console.error("Failed to share session", err) + }, + })) + + const unshareMutation = useMutation(() => ({ + mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }), + onError: (err) => { + console.error("Failed to unshare session", err) + }, + })) + + const titleMutation = useMutation(() => ({ + mutationFn: (input: { id: string; title: string }) => + sdk().api.session.rename({ sessionID: input.id, title: input.title }), + onSuccess: (_, input) => { + sync().set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === input.id) + if (index !== -1) draft.session[index].title = input.title + }), + ) + setTitle("editing", false) + }, + onError: (err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }, + })) + + const shareSession = () => { + const id = sessionID() + if (!id || shareMutation.isPending) return + if (!shareEnabled()) return + shareMutation.mutate(id) + } + + const unshareSession = () => { + const id = sessionID() + if (!id || unshareMutation.isPending) return + if (!shareEnabled()) return + unshareMutation.mutate(id) + } + const copyShareUrl = () => { + const url = shareUrl() + if (!url) return + void navigator.clipboard + .writeText(url) + .then(() => + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: url, + }), + ) + .catch((err: unknown) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }), + ) + } + const selectShareUrlText: JSX.EventHandler = (event) => { + const selection = window.getSelection() + if (!selection) return + const range = document.createRange() + range.selectNodeContents(event.currentTarget) + selection.removeAllRanges() + selection.addRange(range) + } + + createEffect( + on( + sessionKey, + () => + setTitle({ + draft: "", + editing: false, + menuOpen: false, + pendingRename: false, + pendingShare: false, + }), + { defer: true }, + ), + ) + + createEffect( + on( + () => [parentID(), childTaskDescription()] as const, + ([id, description]) => { + if (!id || description) return + if (sync().data.message[id] !== undefined) return + void sync().session.sync(id) + }, + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!sessionID() || parentID()) return + setTitle({ editing: true, draft: titleLabel() ?? "" }) + requestAnimationFrame(() => { + if (!titleRef) return + titleRef.focus() + titleRef.select() + }) + } + + const closeTitleEditor = () => { + if (titleMutation.isPending) return + setTitle("editing", false) + } + + const saveTitleEditor = () => { + const id = sessionID() + if (!id) return + if (titleMutation.isPending) return + + const next = title.draft.trim() + if (!next || next === (titleLabel() ?? "")) { + setTitle("editing", false) + return + } + + titleMutation.mutate({ id, title: next }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + const href = (id: string) => + params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id) + if (parentID) { + navigate(href(parentID)) + return + } + if (nextSessionID) { + navigate(href(nextSessionID)) + return + } + if (params.serverKey) { + tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory }) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync().session.get(sessionID) + if (!session) return + if ((await sdk().protocol) !== "v1") return + + const sessions = sync().data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk() + .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) + .then(() => { + sync().set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + sync().session.evict(sessionID) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] }) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const unarchiveSession = async (sessionID: string) => { + if ((await sdk().protocol) !== "v1") return + await (sdk().client.session.update as Function)({ sessionID, directory: sdk().directory, time: { archived: null } }) + .then(() => void sync().session.sync(sessionID, { force: true })) + .catch((err: unknown) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync().session.get(sessionID) + if (!session) return false + + const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk() + .api.session.remove({ sessionID }) + .then(() => true) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + const removed = new Set([sessionID]) + const byParent = new Map() + for (const item of sync().data.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + + sync().set( + produce((draft) => { + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + for (const id of removed) { + sync().session.evict(id) + } + notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] }) + return true + } + + const navigateParent = () => { + const id = parentID() + if (!id) return + navigate( + params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id), + ) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const name = createMemo( + () => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"), + ) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + if (settings.general.newLayoutDesigns()) + return ( + + + + + + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("session.delete.button")} + + + + ) + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID + + const turnDurationMs = (userMessageID: string) => { + const message = messageByID().get(userMessageID) + if (!message || message.role !== "user") return + const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce( + (max, item) => { + const completed = item.time.completed + if (typeof completed !== "number") return max + if (max === undefined) return completed + return Math.max(max, completed) + }, + undefined, + ) + if (typeof end !== "number") return + if (end < message.time.created) return + return end - message.time.created + } + + const assistantCopyPartID = (userMessageID: string) => { + if (workingTurn(userMessageID)) return null + const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (!message) continue + + const parts = getMsgParts(message.id) + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j] + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id + } + } + } + + const renderAssistantPartGroup = (row: Accessor, onSizeChange?: () => void) => { + if (row().group.type === "context") { + const parts = createMemo(() => { + const group = row().group + if (group.type !== "context") return emptyTools + return group.refs + .map((ref) => getMsgPart(ref.messageID, ref.partID)) + .filter((part): part is ToolPart => part?.type === "tool") + }) + const contextOpenKey = () => `context:${row().group.key}` + const open = createMemo(() => { + return toolOpen[contextOpenKey()] === true + }) + + return ( + setToolOpen(contextOpenKey(), value)} + busy={ + workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key + } + onSizeChange={onSizeChange} + /> + ) + } + + const message = createMemo(() => { + const group = row().group + if (group.type !== "part") return + return messageByID().get(group.ref.messageID) + }) + const part = createMemo(() => { + const group = row().group + if (group.type !== "part") return + return getMsgPart(group.ref.messageID, group.ref.partID) + }) + const defaultOpen = createMemo(() => { + const item = part() + if (!item) return + return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) + }) + + return ( + + {(message) => ( + + {(part) => ( + setToolOpen(part().id, open)} + deferToolContent + virtualizeDiff={false} + onContentRendered={onSizeChange} + /> + )} + + )} + + ) + } + + function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { + const anchor = () => { + const row = input.row() + return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) + } + const previousAssistantPart = () => { + const row = input.row() + return row._tag === "AssistantPart" && row.previousAssistantPart + } + + return ( +
+
+ {input.children} +
+
+ ) + } + + const renderTimelineRow = (row: Accessor, onSizeChange?: () => void) => { + switch (row()._tag) { + case "TurnGap": + return + + ) + + return ( +
+ {renderAttachments()} + 0} + fallback={ + 0}> + + + } + > +
+ + {(skill) => ( + +
+ +
+
+ } + > + +
+ +
+
+ + )} + +
0 ? "true" : undefined}> + + 0}> + + +
+
+ + {renderAttachments()} + 0 || (props.useV2Actions && messageComments().length > 0)}> +
+ + + + + {metaHead()} + + + + + {"\u00A0\u00B7\u00A0"} + + + + + {metaTail()} + + + + + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + revert() + }} + aria-label={i18n.t("ui.message.revertMessage")} + /> + + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+
+
+ ) +} + +type HighlightSegment = { text: string; type?: "file" | "agent" } + +function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) { + const segments = createMemo(() => { + const text = props.text + + const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ + ...props.references + .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) + .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), + ...props.agents + .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) + .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), + ].sort((a, b) => a.start - b.start) + + const result: HighlightSegment[] = [] + let lastIndex = 0 + + for (const ref of allRefs) { + if (ref.start < lastIndex) continue + + if (ref.start > lastIndex) { + result.push({ text: text.slice(lastIndex, ref.start) }) + } + + result.push({ text: text.slice(ref.start, ref.end), type: ref.type }) + lastIndex = ref.end + } + + if (lastIndex < text.length) { + result.push({ text: text.slice(lastIndex) }) + } + + return result + }) + + return {(segment) => {segment.text}} +} + +export function Part(props: MessagePartProps) { + const component = createMemo(() => PART_MAPPING[props.part.type]) + return ( + + + + ) +} + +export interface ToolProps { + input: Record + metadata: Record + tool: string + sessionID?: string + output?: string + status?: string + hideDetails?: boolean + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + deferContent?: boolean + virtualizeDiff?: boolean + onContentRendered?: () => void + forceOpen?: boolean + locked?: boolean +} + +export type ToolComponent = Component + +const state: Record< + string, + { + name: string + render?: ToolComponent + } +> = {} + +export function registerTool(input: { name: string; render?: ToolComponent }) { + state[input.name] = input + return input +} + +export function getTool(name: string) { + return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render +} + +export const ToolRegistry = { + register: registerTool, + render: getTool, +} + +function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { + const value = createMemo(() => props.path || "tool-file") + + return ( + + + + +
+
+ +
+ + {`\u202A${getDirectory(props.path)}\u202C`} + + {getFilename(props.path)} +
+
+
+ {props.actions} + +
+
+
+
+ {props.children} +
+
+ ) +} + +PART_MAPPING["tool"] = function ToolPartDisplay(props) { + const data = useData() + const i18n = useI18n() + const part = () => props.part as ToolPart + if (part().tool === "todowrite") return null + + const hideQuestion = createMemo( + () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), + ) + + const emptyInput: Record = {} + const emptyMetadata: Record = {} + + const input = () => part().state?.input ?? emptyInput + // @ts-expect-error + const partMetadata = () => part().state?.metadata ?? emptyMetadata + const taskId = createMemo(() => { + if (part().tool !== "task") return + const value = partMetadata().sessionId + if (typeof value === "string" && value) return value + }) + const taskHref = createMemo(() => { + if (part().tool !== "task") return + return sessionLink(taskId(), data.sessionHref) + }) + const taskSubtitle = createMemo(() => { + if (part().tool !== "task") return undefined + const value = input().description + if (typeof value === "string" && value) return value + return taskId() + }) + + // amicode: L2 renderer slot — amicode_* tools get the Amicode card (sole stock-code touch) + const render = createMemo(() => + /^amicode_/.test(part().tool) ? AmicodeToolCard : (ToolRegistry.render(part().tool) ?? GenericTool), + ) + const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined) + const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) + + // amicode: hovering the row glances at its node on the brain's map + const brainRef = createMemo(() => amicoBrainRef(part().tool, input())) + return ( + +
emitAmicoBrainHover(brainRef())} + > + + + {(error) => { + const cleaned = error().replace("Error: ", "") + if (part().tool === "question" && cleaned.includes("dismissed this question")) { + return ( +
+ + {i18n.t("ui.messagePart.questions.dismissed")} + +
+ ) + } + return ( + { + if (!data.navigateToSession) return + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + const id = taskId() + if (!id) return + event.preventDefault() + data.navigateToSession(id) + }} + /> + ) + }} +
+ + + +
+
+
+ ) +} + +export function MessageDivider(props: { label: string }) { + return ( +
+
+ + + {props.label} + + +
+
+ ) +} + +PART_MAPPING["compaction"] = function CompactionPartDisplay() { + const i18n = useI18n() + return +} + +PART_MAPPING["text"] = function TextPartDisplay(props) { + const data = useData() + const i18n = useI18n() + const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale())) + const part = () => props.part as TextPart + const interrupted = createMemo( + () => + props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", + ) + + const model = createMemo(() => { + if (props.message.role !== "assistant") return "" + const message = props.message as AssistantMessage + const match = data.store.provider?.all?.get(message.providerID) + return match?.models?.[message.modelID]?.name ?? message.modelID + }) + + const duration = createMemo(() => { + if (props.message.role !== "assistant") return "" + const message = props.message as AssistantMessage + const completed = message.time.completed + const ms = + typeof props.turnDurationMs === "number" + ? props.turnDurationMs + : typeof completed === "number" + ? completed - message.time.created + : -1 + if (!(ms >= 0)) return "" + const total = Math.round(ms / 1000) + if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) }) + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return i18n.t("ui.message.duration.minutesSeconds", { + minutes: numfmt().format(minutes), + seconds: numfmt().format(seconds), + }) + }) + + const meta = createMemo(() => { + if (props.message.role !== "assistant") return "" + const agent = (props.message as AssistantMessage).agent + const items = [ + agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", + model(), + duration(), + interrupted() ? i18n.t("ui.message.interrupted") : "", + ] + return items.filter((x) => !!x).join(" \u00B7 ") + }) + + const streaming = createMemo( + () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", + ) + const text = () => readPartText(data.store.part_text_accum_delta, part()) + const isLastTextPart = createMemo(() => { + const last = (data.store.part?.[props.message.id] ?? []) + .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) + .at(-1) + return last?.id === part().id + }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return isLastTextPart() + if (props.showAssistantCopyPartID === null) return false + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id + return isLastTextPart() + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = text() + if (!content) return + if (await writeClipboard(content)) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( + +
+
+ }> + + +
+ +
+ event.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + {meta()} + + +
+
+
+
+ ) +} + +PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { + const data = useData() + const part = () => props.part as ReasoningPart + const streaming = createMemo( + () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", + ) + const text = () => readPartText(data.store.part_text_accum_delta, part()) + + return ( + +
+ }> + + +
+
+ ) +} + +// AMICODE: the filename in an edit/write trigger opens the file in the editor +// via the bridge (the chat iframe can't). stopPropagation so the tool body's +// expand/collapse doesn't fire alongside. +function OpenableFilename(props: { filename: string; path: string }) { + return ( + {props.filename}}> + + + ) +} + +ToolRegistry.register({ + name: "read", + render(props) { + const data = useData() + const i18n = useI18n() + const args: string[] = [] + if (props.input.offset) args.push("offset=" + props.input.offset) + if (props.input.limit) args.push("limit=" + props.input.limit) + const loaded = createMemo(() => { + if (props.status !== "completed") return [] + const value = props.metadata.loaded + if (!value || !Array.isArray(value)) return [] + return value.filter((p): p is string => typeof p === "string") + }) + return ( + <> + + + {(filepath) => ( + + )} + + + ) + }, +}) + +ToolRegistry.register({ + name: "list", + render(props) { + const i18n = useI18n() + return ( + + +
+ +
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "glob", + render(props) { + const i18n = useI18n() + return ( + + +
+ + + ) + }, +}) + +ToolRegistry.register({ + name: "grep", + render(props) { + const i18n = useI18n() + const args: string[] = [] + if (props.input.pattern) args.push("pattern=" + props.input.pattern) + if (props.input.include) args.push("include=" + props.input.include) + return ( + + +
+ +
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "webfetch", + render(props) { + const i18n = useI18n() + const pending = createMemo(() => props.status === "pending" || props.status === "running") + const url = createMemo(() => { + const value = props.input.url + if (typeof value !== "string") return "" + return value + }) + return ( + + + +
+ +
+
+
+ } + /> + ) + }, +}) + +ToolRegistry.register({ + name: "websearch", + render(props) { + const query = createMemo(() => { + const value = props.input.query + if (typeof value !== "string") return "" + return value + }) + const title = createMemo(() => webSearchProviderLabel(props.metadata.provider)) + + return ( + + + + ) + }, +}) + +ToolRegistry.register({ + name: "task", + render(props) { + const data = useData() + const i18n = useI18n() + const childSessionId = createMemo(() => { + const value = props.metadata.sessionId + if (typeof value === "string" && value) return value + return taskSession(props.input, data.sessionID, data.store.session, data.store.agent) + }) + const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) + const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) + const tone = createMemo(() => agent().color) + const v2Tone = createMemo(() => agent().v2Color) + const subtitle = createMemo(() => { + const value = + typeof props.input.description === "string" && props.input.description + ? props.input.description + : childSessionId() + if (!value) return value + if (props.metadata.background === true) return `${value} (background)` + return value + }) + const running = createMemo(() => props.status === "pending" || props.status === "running") + + const href = createMemo(() => sessionLink(childSessionId(), data.sessionHref)) + const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) + + const open = () => { + const id = childSessionId() + if (!id) return + data.navigateToSession?.(id) + } + + const navigate = (event: MouseEvent) => { + if (!data.navigateToSession) return + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + event.preventDefault() + open() + } + const navigateKey = (event: KeyboardEvent) => { + if (!clickable() || href()) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + open() + } + + const trigger = () => ( +
+
+
+
+ + + + + + } + > + + {/* amicode: H-glyph at the legacy spinner site (patch #10 sweep); + SessionProgressIndicatorV2 kept for the v2 task card */} + }> + + + + + {title()} + + {subtitle()} + +
+
+
+ +
+ +
+
+
+ ) + + return ( + + ) + }, +}) + +ToolRegistry.register({ + name: "shell", + render(props) { + const i18n = useI18n() + const pending = () => props.status === "pending" || props.status === "running" + const sawPending = pending() + const text = createMemo(() => { + const cmd = props.input.command ?? props.metadata.command ?? "" + const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n") + return `$ ${cmd}${out ? "\n\n" + out : ""}` + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = text() + if (!content) return + if (await writeClipboard(content)) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( + ( +
+
+ + + + + + +
+
+ )} + > +
+
+ + } + size="normal" + variant="ghost-muted" + onMouseDown={(e) => e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+
+              {text()}
+            
+
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "edit", + render(props) { + const i18n = useI18n() + const fileComponent = useFileComponent() + const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) + const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") + const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => props.status === "pending" || props.status === "running" + const diffSource = createMemo( + () => { + const filediff = props.metadata?.filediff + if (!filediff) return + return { + file: filediff.file || props.input.filePath || "", + patch: typeof filediff.patch === "string" ? filediff.patch : undefined, + before: typeof filediff.before === "string" ? filediff.before : undefined, + after: typeof filediff.after === "string" ? filediff.after : undefined, + } + }, + undefined, + { + equals: (a, b) => + a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after, + }, + ) + + const fileCompProps = createMemo(() => { + try { + const source = diffSource() + if (source) { + const fileDiff = resolveFileDiff(source) + if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" } + } + } catch {} + + return { + before: { + name: props.metadata?.filediff?.file || props.input.filePath, + contents: props.metadata?.filediff?.before || props.input.oldString || "", + }, + after: { + name: props.metadata?.filediff?.file || props.input.filePath, + contents: props.metadata?.filediff?.after || props.input.newString || "", + }, + } + }) + + return ( +
+ +
+
+ + + + + + +
+ +
+ {getDirectory(props.input.filePath!)} +
+
+
+
+ + + +
+
+ } + > + + + + + } + > +
+ +
+ +
+ + +
+ ) + }, +}) + +ToolRegistry.register({ + name: "write", + render(props) { + const i18n = useI18n() + const fileComponent = useFileComponent() + const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) + const path = createMemo(() => props.input.filePath || "") + const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => props.status === "pending" || props.status === "running" + return ( +
+ +
+
+ + + + + + +
+ +
+ {getDirectory(props.input.filePath!)} +
+
+
+
{/* */}
+
+ } + > + + +
+ +
+
+
+ + + + ) + }, +}) + +ToolRegistry.register({ + name: "patch", + render(props) { + const i18n = useI18n() + const fileComponent = useFileComponent() + const files = createMemo(() => patchFiles(props.metadata.files)) + const pending = createMemo(() => props.status === "pending" || props.status === "running") + const single = createMemo(() => { + const list = files() + if (list.length !== 1) return + return list[0] + }) + const [expanded, setExpanded] = createSignal([]) + let seeded = false + + createEffect(() => { + const list = files() + if (list.length === 0) return + if (seeded) return + seeded = true + setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) + }) + + const subtitle = createMemo(() => { + const count = files().length + if (count === 0) return "" + return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + }) + + return ( + + + 0}> + setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(file) => { + const active = createMemo(() => expanded().includes(file.filePath)) + const [visible, setVisible] = createSignal(false) + + createEffect(() => { + if (!active()) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }) + + return ( + + + +
+
+ +
+ + {`\u202A${getDirectory(file.relativePath)}\u202C`} + + {getFilename(file.relativePath)} +
+
+
+ + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
+
+
+ + } + > +
+ +
+
+ + + + + {getFilename(single()!.relativePath)} + +
+ +
+ {getDirectory(single()!.relativePath)} +
+
+
+
+ + + +
+
+ } + > + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + } + > +
+ +
+
+ + +
+ ) + }, +}) + +ToolRegistry.register({ + name: "todowrite", + render(props) { + const i18n = useI18n() + const todos = createMemo(() => { + const meta = props.metadata?.todos + if (Array.isArray(meta)) return meta + + const input = props.input.todos + if (Array.isArray(input)) return input + + return [] + }) + + const subtitle = createMemo(() => { + const list = todos() + if (list.length === 0) return "" + return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}` + }) + + return ( + + +
+ + {(todo: Todo) => ( + + + {todo.content} + + + )} + +
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "question", + render(props) { + const i18n = useI18n() + const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) + const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) + const completed = createMemo(() => answers().length > 0) + + const subtitle = createMemo(() => { + const count = questions().length + if (count === 0) return "" + if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) + return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` + }) + + return ( + + +
+ + {(q, i) => { + const answer = () => answers()[i()] ?? [] + return ( +
+
{q.question}
+
{answer().join(", ") || i18n.t("ui.question.answer.none")}
+
+ ) + }} +
+
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "skill", + render(props) { + const i18n = useI18n() + // AMICODE: label the kind, then name it. `ui.tool.skill` used to be reachable only as a + // fallback for a missing input.name — which never happens — so an activated skill rendered + // as a bare word ("brainstorming") indistinguishable from any other tool. The kind now + // always renders, with the skill's own name beside it. + const name = createMemo(() => props.input.name?.trim() || "") + const body = createMemo(() => skillBody(props.output)) + // AMICODE: the skill tool result carries its base dir in metadata — the chip + // links to the SKILL.md source file (opens in the editor via the bridge). + const skillPath = createMemo(() => { + const dir = props.metadata?.dir + return typeof dir === "string" && dir !== "" ? `${dir}/SKILL.md` : undefined + }) + + const trigger = () => ( +
+
+ +
+
+ ) + + return ( + + + {/* AMICODE: an opened skill is a FILE, not more conversation. tool-output carries no + surface of its own, so the instructions flowed as bare prose; amc-skill-file gives + them a bounded, tinted panel. The chip above stays flush with the other Amico chips + — shared left alignment is the transcript's spine, and indenting the row broke it. */} +
+ +
+
+
+ ) + }, +}) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/session-retry.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/session-retry.tsx new file mode 100644 index 00000000..4d34b747 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/session-retry.tsx @@ -0,0 +1,74 @@ +import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner" +import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { Card } from "@opencode-ai/ui/card" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +export function SessionRetry(props: { status: SessionStatus; show?: boolean }) { + const i18n = useI18n() + const retry = createMemo(() => { + if (props.status.type !== "retry") return + return props.status + }) + const [seconds, setSeconds] = createSignal(0) + createEffect( + on(retry, (current) => { + if (!current) return + const update = () => { + const next = retry()?.next + if (!next) return + setSeconds(Math.round((next - Date.now()) / 1000)) + } + update() + const timer = setInterval(update, 1000) + onCleanup(() => clearInterval(timer)) + }), + ) + const message = createMemo(() => { + const current = retry() + if (!current) return "" + if (current.message.includes("exceeded your current quota") && current.message.includes("gemini")) { + return i18n.t("ui.sessionTurn.retry.geminiHot") + } + if (current.message.length > 80) return current.message.slice(0, 80) + "..." + return current.message + }) + const truncated = createMemo(() => { + const current = retry() + if (!current) return false + return current.message.length > 80 + }) + const info = createMemo(() => { + const current = retry() + if (!current) return "" + const count = Math.max(0, seconds()) + const delay = count > 0 ? i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: count }) : "" + const retrying = i18n.t("ui.sessionTurn.retry.retrying") + const line = [retrying, delay].filter(Boolean).join(" ") + if (!line) return i18n.t("ui.sessionTurn.retry.attempt", { attempt: current.attempt }) + return i18n.t("ui.sessionTurn.retry.attemptLine", { line, attempt: current.attempt }) + }) + + return ( + +
+ +
+ +
+ {message()}
}> + +
+ {message()} +
+
+ + {(line) =>
{line()}
}
+
+
+ + +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.css b/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.css new file mode 100644 index 00000000..e4f9d6be --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.css @@ -0,0 +1,252 @@ +[data-component="session-turn"] { + --sticky-header-height: calc(var(--session-title-height, 0px) + 24px); + height: 100%; + min-height: 0; + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: flex-start; + + [data-slot="session-turn-content"] { + flex-grow: 1; + width: 100%; + height: 100%; + min-width: 0; + overflow-y: auto; + scrollbar-width: none; + } + + [data-slot="session-turn-content"]::-webkit-scrollbar { + display: none; + } + + [data-slot="session-turn-message-container"] { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + min-width: 0; + gap: 0px; + overflow-anchor: none; + } + + [data-slot="session-turn-message-content"] { + margin-top: 0; + width: 100%; + min-width: 0; + max-width: 100%; + } + + [data-slot="session-turn-compaction"] { + width: 100%; + min-width: 0; + align-self: stretch; + } + + [data-slot="session-turn-thinking"] { + display: flex; + /* baseline: aligns the heading with the thinking block's VERB row — the + block's first baseline is the verb's by construction (thinking-line.tsx), + so a center-aligned two-row block never leaves the heading floating + between the verb and meta rows */ + align-items: baseline; + gap: 8px; + margin-top: 12px; + width: 100%; + min-width: 0; + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: 20px; + min-height: 20px; + + /* the indicator block is unsqueezable — the sibling heading truncates + instead (TextReveal truncate). The old flex row once let the heading + squeeze the meta until it shattered mid-phrase across three lines. */ + > [data-slot="amc-thinking"] { + flex-shrink: 0; + } + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-component="text-reveal"].session-turn-thinking-heading { + flex: 1 1 auto; + min-width: 0; + color: var(--text-weaker); + font-weight: var(--font-weight-regular); + } + + .error-card { + color: var(--v2-text-text-base); + max-height: 240px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + overflow-y: auto; + } + + [data-slot="session-turn-assistant-content"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 12px; + } + + [data-slot="session-turn-diffs"] { + width: 100%; + min-width: 0; + } + + [data-slot="session-turn-diffs-header"] { + display: flex; + align-items: center; + gap: 8px; + padding-top: 4px; + padding-bottom: 12px; + position: sticky; + top: var(--sticky-accordion-top, 0px); + z-index: 20; + background-color: var(--v2-background-bg-base); + height: 44px; + } + + [data-slot="session-turn-diffs-label"] { + font-variant-numeric: tabular-nums; + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + } + + [data-slot="session-turn-diffs-toggle"] { + color: var(--text-interactive-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; + margin-left: 4px; + } + + [data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] { + opacity: 1; + } + + [data-component="session-turn-diffs-group"][data-show-all] [data-slot="session-turn-diffs-toggle"] { + opacity: 1; + } + + [data-slot="session-turn-diffs-more"] { + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-large); + margin-top: 12px; + padding: 0 0 6px; + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: var(--text-link-base); + } + } + + [data-component="session-turn-diffs-content"] { + padding-top: 0px; + display: flex; + flex-direction: column; + } + + [data-slot="session-turn-diff-trigger"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-width: 0; + } + + [data-slot="session-turn-diff-path"] { + display: flex; + flex-grow: 1; + min-width: 0; + + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-large); + } + + [data-slot="session-turn-diff-directory"] { + color: var(--text-base); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + } + + [data-slot="session-turn-diff-filename"] { + min-width: 0; + color: var(--text-strong); + font-weight: var(--font-weight-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-slot="session-turn-diff-meta"] { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 10px; + } + + [data-slot="session-turn-diff-chevron"] { + display: inline-flex; + color: var(--icon-weaker); + transform: rotate(-90deg); + transition: transform 0.15s ease; + } + + [data-slot="accordion-item"][data-expanded] [data-slot="session-turn-diff-chevron"] { + transform: rotate(0deg); + } + + [data-slot="session-turn-diff-view"] { + background-color: var(--surface-inset-base); + width: 100%; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + } + + [data-slot="session-turn-diff-view"]::-webkit-scrollbar { + display: none; + } +} + +body:not([data-new-layout]) [data-component="session-turn"] { + .error-card { + color: var(--text-on-critical-base); + } + + [data-slot="session-turn-diffs-header"] { + background-color: var(--background-stronger); + } +} + +[data-slot="session-turn-list"] { + gap: 24px; +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/tool-error-card.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-error-card.tsx new file mode 100644 index 00000000..11c6ab82 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-error-card.tsx @@ -0,0 +1,163 @@ +import { type ComponentProps, createMemo, Show, splitProps } from "solid-js" +import { createStore } from "solid-js/store" +import { Card, CardDescription } from "@opencode-ai/ui/card" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { copyTextToClipboard } from "../util/clipboard" + +export interface ToolErrorCardProps extends Omit, "children" | "variant"> { + tool: string + error: string + title?: string + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + subtitle?: string + href?: string + onSubtitleClick?: (event: MouseEvent) => void +} + +export function ToolErrorCard(props: ToolErrorCardProps) { + const i18n = useI18n() + const [state, setState] = createStore({ + open: props.defaultOpen ?? false, + copied: false, + }) + const open = () => props.open ?? state.open + const copied = () => state.copied + const [split, rest] = splitProps(props, [ + "tool", + "error", + "title", + "defaultOpen", + "open", + "onOpenChange", + "subtitle", + "href", + "onSubtitleClick", + ]) + const setOpen = (value: boolean) => { + if (props.open === undefined) setState("open", value) + props.onOpenChange?.(value) + } + const name = createMemo(() => { + if (split.title) return split.title + const map: Record = { + read: "ui.tool.read", + list: "ui.tool.list", + glob: "ui.tool.glob", + grep: "ui.tool.grep", + task: "ui.tool.task", + webfetch: "ui.tool.webfetch", + websearch: "ui.tool.websearch", + bash: "ui.tool.shell", + shell: "ui.tool.shell", + patch: "ui.tool.patch", + apply_patch: "ui.tool.patch", + question: "ui.tool.questions", + } + const key = map[split.tool] + if (!key) return split.tool + if (!key.includes(".")) return key + return i18n.t(key) + }) + const cleaned = createMemo(() => split.error.replace(/^Error:\s*/, "").trim()) + const tail = createMemo(() => { + const value = cleaned() + const prefix = `${split.tool} ` + if (value.startsWith(prefix)) return value.slice(prefix.length) + return value + }) + + const subtitle = createMemo(() => { + if (split.subtitle) return split.subtitle + const parts = tail().split(": ") + if (parts.length <= 1) return i18n.t("ui.toolErrorCard.failed") + const head = (parts[0] ?? "").trim() + if (!head) return i18n.t("ui.toolErrorCard.failed") + return head[0] ? head[0].toUpperCase() + head.slice(1) : i18n.t("ui.toolErrorCard.failed") + }) + + const body = createMemo(() => { + const parts = tail().split(": ") + if (parts.length <= 1) return cleaned() + return parts.slice(1).join(": ").trim() || cleaned() + }) + + const copy = async () => { + const text = cleaned() + if (!text) return + await copyTextToClipboard(text) + setState("copied", true) + setTimeout(() => setState("copied", false), 2000) + } + + return ( + + + +
+ + +
+
+ +
+ +
+ + e.preventDefault()} + onClick={(e) => { + e.stopPropagation() + void copy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")} + /> + +
+
+ {(value) => {value()}} +
+
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/tool-status-title.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-status-title.tsx new file mode 100644 index 00000000..94e1670a --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-status-title.tsx @@ -0,0 +1,131 @@ +import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +function common(active: string, done: string) { + const a = Array.from(active) + const b = Array.from(done) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + prefix: a.slice(0, i).join(""), + active: a.slice(i).join(""), + done: b.slice(i).join(""), + } +} + +function contentWidth(el: HTMLSpanElement | undefined) { + if (!el) return + return `${Math.ceil(el.getBoundingClientRect().width)}px` +} + +export function ToolStatusTitle(props: { + active: boolean + activeText: string + doneText: string + class?: string + split?: boolean +}) { + const split = createMemo(() => common(props.activeText, props.doneText)) + const suffix = createMemo( + () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, + ) + const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + + const [state, setState] = createStore({ + active: props.active, + animating: false, + width: undefined as string | undefined, + }) + const width = () => state.width + const active = () => state.active + const animating = () => state.animating + let activeRef: HTMLSpanElement | undefined + let doneRef: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined + let frame: number | undefined + let finishTimer: ReturnType | undefined + + const finish = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + if (finishTimer !== undefined) clearTimeout(finishTimer) + frame = undefined + finishTimer = undefined + setState("animating", false) + setState("width", undefined) + } + + const animate = () => { + const first = contentWidth(widthRef) + const next = props.active + finish() + setState("active", next) + if (!first) return + + setState("animating", true) + setState("width", first) + frame = requestAnimationFrame(() => { + frame = undefined + const last = contentWidth(next ? activeRef : doneRef) + if (!last) { + finish() + return + } + if (first !== last) setState("width", last) + finishTimer = setTimeout(finish, 600) + }) + } + + createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true })) + + onCleanup(() => { + finish() + }) + + return ( + + + + + {activeTail()} + + + + + {doneTail()} + + + + } + > + + + {split().prefix} + + + + + {activeTail()} + + + + + {doneTail()} + + + + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts b/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts new file mode 100644 index 00000000..87f3a852 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts @@ -0,0 +1,34 @@ +// Clipboard write that survives the Amicode VS Code webview. Inside the +// sandboxed chat iframe, navigator.clipboard and execCommand("copy") never +// reach the OS clipboard — the extension host owns it, so post the +// "clipboard-write" envelope over the same bridge the app uses +// (prompt-input/clipboard-bridge.ts). Unframed (desktop shell, plain +// browser), the native paths stand. session-ui twin of the app's helper +// (@opencode-ai/ui exports only components/*.tsx, so util/clipboard is +// unreachable across the package boundary). +export async function copyTextToClipboard(text: string): Promise { + if (typeof window !== "undefined" && window.parent !== window) { + window.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*") + return true // fire-and-forget, matching the app's writeClipboardViaBridge + } + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return true + } + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return false + return clipboard.writeText(text).then( + () => true, + () => false, + ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/attachments.ts new file mode 100644 index 00000000..ac55b24e --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -0,0 +1,287 @@ +import { onMount } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types" + +const accepted = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".c", + ".cc", + ".cjs", + ".conf", + ".cpp", + ".css", + ".csv", + ".cts", + ".env", + ".go", + ".gql", + ".graphql", + ".h", + ".hh", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mdx", + ".mjs", + ".mts", + ".py", + ".rb", + ".rs", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +] + +type PromptTarget = { + current: () => PromptInputV2Prompt + cursor: () => number | undefined + set: (prompt: PromptInputV2Prompt, cursor?: number) => void +} + +export type PromptInputV2AttachmentConfig = { + picker?: ( + options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, + onFile: (file: File) => Promise, + ) => Promise + directory: () => string + isDialogActive: () => boolean + warn: () => void + duplicate: () => void + onError: (error: unknown) => void + readClipboardImage?: () => Promise + /** Clipboard-text reader for framed hosts where the paste event delivers no + * data (the Amicode VS Code webview — see app's prompt-input/clipboard-bridge). + * Resolves "" when unavailable. */ + readClipboardText?: () => Promise + getPathForFile?: (file: File) => string + store?: (file: File) => Promise<{ id: string; url: string }> +} + +export function createPromptInputV2Attachments( + input: PromptInputV2AttachmentConfig & { + capture: () => PromptTarget + editor: () => HTMLElement | undefined + focusEditor: () => void + addPart: (part: PromptInputV2Prompt[number]) => boolean + setDraggingType: (type: "image" | "@mention" | null) => void + }, +) { + const capture = () => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } + } + const add = async (file: File, toast = true, target = capture(), clipboard = false) => { + if (!target) return false + const mime = await attachmentMime(file) + if (!mime) { + if (toast) input.warn() + return false + } + const blob = input.store ? await input.store(file) : await blobReference(file) + const sourcePath = input.getPathForFile?.(file) || undefined + // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical + // clipboard content is matched on bytes alone. + const duplicate = target.prompt + .current() + .some( + (part) => + part.type === "image" && + part.blob.id === blob.id && + (sourcePath + ? part.sourcePath === sourcePath + : !part.sourcePath && (clipboard || part.filename === file.name)), + ) + if (duplicate) { + input.duplicate() + return true + } + const attachment: PromptInputV2Attachment = { + type: "image", + id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), + filename: file.name, + sourcePath, + mime, + blob, + } + target.prompt.set([...target.prompt.current(), attachment], target.cursor) + return true + } + const addAttachments = async (files: File[], toast = true, target = capture()) => { + const found = await files.reduce(async (result, file) => { + const previous = await result + return (await add(file, false, target)) || previous + }, Promise.resolve(false)) + if (!found && files.length > 0 && toast) input.warn() + return found + } + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + const target = capture() + if (!target) return + event.preventDefault() + event.stopPropagation() + const files = Array.from(clipboardData.items).flatMap((item) => { + if (item.kind !== "file") return [] + const file = item.getAsFile() + return file ? [file] : [] + }) + if (files.length > 0) { + await addAttachments(files, true, target) + return + } + let plainText = clipboardData.getData("text/plain") ?? "" + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file && (await add(file, true, target, true))) return + } + // Framed host fallback (the Amicode webview): the paste event fired but + // carried nothing readable, so ask the host clipboard before giving up. + if (!plainText && input.readClipboardText) { + plainText = await input.readClipboardText() + } + if (!plainText) return + const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText + const put = () => { + if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor() + return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + } + if (text.includes("\n") || largePaste(text)) { + put() + return + } + if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return + put() + } + const handleDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + event.preventDefault() + input.setDraggingType(null) + const plainText = event.dataTransfer?.getData("text/plain") + if (plainText?.startsWith("file:")) { + const path = plainText.slice("file:".length) + input.focusEditor() + input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 }) + return + } + const files = event.dataTransfer?.files + if (files) await addAttachments(Array.from(files)) + } + + onMount(() => { + makeEventListener(document, "dragover", (event) => { + if (input.isDialogActive()) return + event.preventDefault() + if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image") + else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention") + }) + makeEventListener(document, "dragleave", (event) => { + if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null) + }) + makeEventListener(document, "drop", handleDrop) + }) + + return { + addAttachments, + handlePaste, + handleDrop, + pick(fallback: () => void) { + if (!input.picker) { + fallback() + return + } + void input + .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file)) + .catch(input.onError) + }, + } +} + +const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) + +async function blobReference(file: File) { + const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + return { id, url: URL.createObjectURL(file) } +} +const imageExtensions = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"], +]) +const textMimes = new Set([ + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", +]) + +async function attachmentMime(file: File) { + const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" + if (imageMimes.has(type) || type === "application/pdf") return type + const index = file.name.lastIndexOf(".") + const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase() + const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) + if ((!type || type === "application/octet-stream") && fallback) return fallback + if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { + return "text/plain" + } + const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer()) + if (bytes.some((byte) => byte === 0)) return + const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length + if (bytes.length > 0 && control / bytes.length > 0.3) return + return "text/plain" +} + +function cursorPosition(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!editor.contains(range.startContainer)) return 0 + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + return before.toString().replace(/\u200B/g, "").length +} + +function largePaste(text: string) { + if (text.length >= 8000) return true + return text.split("\n").length - 1 >= 120 +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx new file mode 100644 index 00000000..3fe5b197 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -0,0 +1,724 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { AttachmentCardV2 } from "../attachment-card-v2" +import { CommentCardV2 } from "../comment-card-v2" +import { typeLabel } from "../../../components/message-file" +import type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Prompt, + PromptInputV2Suggestion, +} from "./types" +import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" +import "./attachments.css" + +export type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Suggestion, +} from "./types" + +export type PromptInputV2Mode = "normal" | "shell" + +export type PromptInputV2Props = { + controller: PromptInputV2Interaction + disabled?: boolean + readOnly?: boolean + borderUnderlay?: boolean + class?: string + modelControl?: JSX.Element + /** + * Optional control rendered in the bottom row's right anchor, immediately + * left of the send button (amicode/opencode#116: the report-a-bug button). + * Follows the modelControl optional-control pattern: pass `undefined` and + * nothing renders — the row's layout does not shift. + */ + trailingControl?: JSX.Element + variantControlVisible?: boolean + attachKeybind?: string[] + attachShortcut?: string +} + +export function PromptInputV2(props: PromptInputV2Props) { + const state = props.controller.state + const view = props.controller.view + let editor: HTMLDivElement | undefined + let localInput = false + const updateCursor = () => { + if (!editor || !window.getSelection()?.isCollapsed) return + props.controller.onCursor(promptInputV2Cursor(editor)) + } + const mode = createMemo(() => state.mode) + const buttons = createMemo(() => ({ + opacity: mode() === "normal" ? 1 : 0, + "pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const), + transition: "opacity 200ms ease", + })) + + createEffect(() => { + const parts = props.controller.parts() + if (!editor) return + if (localInput) { + localInput = false + return + } + renderPromptInputV2Editor(editor, parts) + }) + + return ( +
+ { + const list = event.currentTarget.files + if (list) props.controller.addAttachments(Array.from(list)) + event.currentTarget.value = "" + }} + /> + + props.controller.dispatch({ type: "popover.active", id: item.id })} + onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })} + /> + +
{ + event.preventDefault() + if (!props.disabled) props.controller.submit() + }} + onDragEnter={props.controller.onDragEnter} + onDragOver={props.controller.onDragOver} + onDragLeave={props.controller.onDragLeave} + onDrop={props.controller.onDrop} + > + +
+ Drop files to attach +
+
+ + + props.controller.removeAttachment(attachment.id)} + onCommentClick={(comment) => props.controller.toggleContext(comment.key)} + onCommentRemove={(comment) => props.controller.removeContext(comment.key)} + /> + + +
+
{ + editor = element + props.controller.setEditor(element) + renderPromptInputV2Editor(element, props.controller.parts()) + }} + data-component="prompt-input" + role="textbox" + aria-multiline="true" + aria-label="Prompt" + contenteditable={!props.disabled && !props.readOnly} + autocapitalize={state.mode === "normal" ? "sentences" : "off"} + autocorrect={state.mode === "normal" ? "on" : "off"} + spellcheck={state.mode === "normal"} + // @ts-expect-error + autocomplete="off" + class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" + classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} + onInput={(event) => { + const cursor = promptInputV2Cursor(event.currentTarget) + const prompt = parsePromptInputV2Editor(event.currentTarget) + const images = props.controller.parts().filter((part) => part.type === "image") + localInput = true + props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) + }} + onKeyDown={(event) => { + if (props.controller.onKeyDown(event)) return + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault() + if (event.repeat) return + props.controller.submit() + } + }} + onKeyUp={updateCursor} + onPointerUp={updateCursor} + onPaste={props.controller.onPaste} + onFocus={() => props.controller.dispatch({ type: "focus.editor" })} + /> + +
+ {view.placeholder?.() ?? + (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")} +
+
+
+ +
+
+ + + {(control) => ( + + )} + + + {(control) => ( + + )} + + } + > + {props.modelControl} + + + {(control) => ( + 1}> + + + )} + +
+ + {(control) =>
{control}
} +
+ +
+ +
+ ) +} + +function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) { + const active = document.activeElement === editor + editor.replaceChildren( + ...prompt.flatMap((part) => { + if (part.type === "image") return [] + if (part.type === "text") return [document.createTextNode(part.content)] + const mention = document.createElement("span") + mention.textContent = part.content + mention.contentEditable = "false" + mention.dataset.mention = + part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type + if (part.type === "agent") mention.dataset.name = part.name + if (part.type === "file") { + mention.dataset.path = part.path + if (part.mime) mention.dataset.mime = part.mime + if (part.filename) mention.dataset.filename = part.filename + } + return [mention] + }), + ) + if (!active) return + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) +} + +function parsePromptInputV2Editor(editor: HTMLDivElement) { + const parts: Exclude[] = [] + let buffer = "" + let position = 0 + + const flush = () => { + if (!buffer) return + parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length }) + position += buffer.length + buffer = "" + } + const mention = (element: HTMLElement) => { + flush() + const content = element.textContent ?? "" + if (element.dataset.mention === "agent") { + parts.push({ + type: "agent", + name: element.dataset.name ?? content.slice(1), + content, + start: position, + end: position + content.length, + }) + position += content.length + return + } + parts.push({ + type: "file", + path: element.dataset.path ?? content.slice(1), + content, + start: position, + end: position + content.length, + ...(element.dataset.mime ? { mime: element.dataset.mime } : {}), + ...(element.dataset.filename ? { filename: element.dataset.filename } : {}), + }) + position += content.length + } + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (!(node instanceof HTMLElement)) return + if (node.dataset.mention) { + mention(node) + return + } + if (node.tagName === "BR") { + buffer += "\n" + return + } + Array.from(node.childNodes).forEach(visit) + } + + Array.from(editor.childNodes).forEach((node, index, nodes) => { + visit(node) + if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n" + }) + flush() + if ( + parts.every((part) => part.type === "text") && + parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "") + ) { + return [{ type: "text" as const, content: "", start: 0, end: 0 }] + } + if (parts.length > 0) return parts + return [{ type: "text" as const, content: "", start: 0, end: 0 }] +} + +function promptInputV2Cursor(editor: HTMLDivElement) { + const selection = window.getSelection() + if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(editor) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length +} + +export function PromptInputV2Attachments(props: { + attachments: PromptInputV2Attachment[] + comments?: PromptInputV2Comment[] + activeCommentID?: string + removeLabel: string + onAttachmentClick?: (attachment: PromptInputV2Attachment) => void + onAttachmentRemove: (attachment: PromptInputV2Attachment) => void + onCommentClick?: (comment: PromptInputV2Comment) => void + onCommentRemove?: (comment: PromptInputV2Comment) => void +}) { + return ( + 0 || (props.comments?.length ?? 0) > 0}> +
+
+ + {(comment) => ( +
+ + props.onCommentClick?.(comment)} + /> + + +
+ )} +
+ + {(attachment) => ( +
+ + + {typeLabel(attachment.filename, attachment.mime)} + + } + > + {attachment.filename} props.onAttachmentClick?.(attachment)} + /> +
+ + + +
+ )} + +
+
+
+
+ + ) +} + +export function PromptInputV2AddMenu(props: { + disabled?: boolean + title: string + keybind?: string[] + attachLabel: string + attachShortcut?: string + commandsLabel: string + contextLabel: string + shellLabel: string + onAttach: () => void + onCommands: () => void + onContext: () => void + onShell: () => void +}) { + return ( + + {props.title} + + + } + > + + } + variant="ghost-muted" + size="large" + disabled={props.disabled} + aria-label={props.title} + /> + + + + {props.attachLabel} + + + + {props.commandsLabel} + + + {props.contextLabel} + + + {props.shellLabel} + + + + + + ) +} + +function PromptInputV2ConfiguredSelect(props: { + title: string + keybind?: string[] + control: PromptInputV2SelectControl + model?: boolean +}) { + const current = () => props.control.current() + const providerID = () => props.control.options().find((option) => option.id === current())?.providerID + return ( + + + + } + onSelect={props.control.onSelect} + /> + ) +} + +export function PromptInputV2Select(props: { + title: string + keybind?: string[] + options: PromptInputV2Option[] + current: string + currentIcon?: JSX.Element + class?: string + onOpenChange?: (open: boolean) => void + onSelect: (id: string) => void +}) { + return ( + + {props.title} + + + } + > + + + {props.currentIcon} + + {props.options.find((option) => option.id === props.current)?.label ?? props.current} + + + + + + + + + + {(option) => ( + + {option.label} + + )} + + + + + + + ) +} + +export function PromptInputV2Popover(props: { + emptyLabel: string + items: PromptInputV2Suggestion[] + activeID?: string + search?: { + value: string + label: string + placeholder: string + onValueChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + } + onActiveChange: (item: PromptInputV2Suggestion) => void + onSelect: (item: PromptInputV2Suggestion) => void +}) { + return ( +
event.preventDefault()} + > + + {(search) => ( +
+ requestAnimationFrame(() => element.focus())} + value={search().value} + aria-label={search().label} + placeholder={search().placeholder} + class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => search().onValueChange(event.currentTarget.value)} + onKeyDown={(event) => search().onKeyDown(event)} + onMouseDown={(event) => event.stopPropagation()} + /> +
+ )} +
+ 0} + fallback={
{props.emptyLabel}
} + > + + {(item) => ( + + )} + +
+
+ ) +} + +export function PromptInputV2SubmitButton(props: { + mode: PromptInputV2Mode + stopping: boolean + disabled: boolean + sendLabel: string + stopLabel: string + onSubmit: () => void + onStop: () => void +}) { + return ( + + { + event.preventDefault() + event.stopPropagation() + if (props.stopping) { + props.onStop() + return + } + props.onSubmit() + }} + /> + + ) +} + +function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) { + if (props.item.kind === "agent") return + if (props.item.kind === "command") return null + return ( + + ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx new file mode 100644 index 00000000..c62a32f7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -0,0 +1,304 @@ +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import type { SelectedLineRange } from "@pierre/diffs" +import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { useFileComponent } from "@opencode-ai/ui/context/file" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { mediaKindFromPath } from "../../pierre/media" +import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" +import { copyTextToClipboard } from "../../util/clipboard" +import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import { normalize, text, type ViewDiff } from "../../components/session-diff" +import type { + SessionReviewComment, + SessionReviewCommentActions, + SessionReviewCommentDelete, + SessionReviewCommentUpdate, + SessionReviewDiffStyle, + SessionReviewFocus, + SessionReviewLineComment, +} from "../../components/session-review" +import type { SessionReviewExpandMode } from "./session-review-v2" +import { createLineCommentControllerV2 } from "./line-comment-annotations-v2" +import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize" +import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import "./session-review-v2.css" + +type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff + +export type SessionReviewFilePreviewV2Props = { + file: string + diff: ReviewDiff + diffStyle: SessionReviewDiffStyle + expandMode?: SessionReviewExpandMode + readFile?: (path: string) => Promise + onLineComment?: (comment: SessionReviewLineComment) => void + onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void + onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void + lineCommentActions?: SessionReviewCommentActions + comments?: SessionReviewComment[] + focusedComment?: SessionReviewFocus | null + onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void +} + +function statusLabel(status: ViewDiff["status"]) { + if (status === "added") return "A" + if (status === "deleted") return "D" + return "M" +} + +function statusType(status: ViewDiff["status"]) { + if (status === "added") return "added" + if (status === "deleted") return "deleted" + return "modified" +} + +function selectionSide(range: SelectedLineRange) { + return range.endSide ?? range.side ?? "additions" +} + +function selectionPreview(diff: ViewDiff, range: SelectedLineRange) { + const side = selectionSide(range) + const contents = text(diff, side) + if (contents.length === 0) return undefined + return previewSelectedLines(contents, range) +} + +function ReviewCommentMenuV2(props: { + labels: SessionReviewCommentActions + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( +
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + + {props.labels.editLabel} + {props.labels.deleteLabel} + + + +
+ ) +} + +export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Props) { + const i18n = useI18n() + const fileComponent = useFileComponent() + let scrollRef: HTMLDivElement | undefined + let focusToken = 0 + + const [store, setStore] = createStore({ + selection: null as SelectedLineRange | null, + commenting: null as SelectedLineRange | null, + opened: null as string | null, + }) + + const view = createMemo(() => ({ + ...normalize(props.diff), + preloaded: "preloaded" in props.diff ? props.diff.preloaded : undefined, + })) + const diffCanRender = createMemo(() => view().additions !== 0 || view().deletions !== 0) + const mediaKind = createMemo(() => mediaKindFromPath(props.file)) + const comments = createMemo(() => (props.comments ?? []).filter((comment) => comment.file === props.file)) + const commentedLines = createMemo(() => comments().map((comment) => comment.selection)) + const lineCommentsEnabled = () => props.onLineComment != null + + const commentsUi = createLineCommentControllerV2({ + comments, + label: i18n.t("ui.lineComment.submit"), + draftKey: () => props.file, + state: { + opened: () => store.opened, + setOpened: (id) => setStore("opened", id), + selected: () => store.selection, + setSelected: (range) => setStore("selection", range), + commenting: () => store.commenting, + setCommenting: (range) => setStore("commenting", range), + }, + getSide: selectionSide, + onSubmit: ({ comment, selection }) => { + props.onLineComment?.({ + file: props.file, + selection, + comment, + preview: selectionPreview(view(), selection), + }) + }, + onUpdate: ({ id, comment, selection }) => { + props.onLineCommentUpdate?.({ + id, + file: props.file, + selection, + comment, + preview: selectionPreview(view(), selection), + }) + }, + onDelete: (comment) => { + props.onLineCommentDelete?.({ + id: comment.id, + file: props.file, + }) + }, + editSubmitLabel: props.lineCommentActions?.saveLabel, + renderCommentActions: props.lineCommentActions + ? (comment, controls) => ( + + ) + : undefined, + }) + + onCleanup(() => { + focusToken++ + }) + + createEffect(() => { + const focus = props.focusedComment + if (!focus) return + if (focus.file !== props.file) { + // The focused file has no mounted preview (e.g. not in the current diff + // set); clear the focus anyway so it cannot hijack a later diff refresh. + // V1 clears unconditionally the same way. + untrack(() => { + const token = focusToken + requestAnimationFrame(() => { + if (token !== focusToken) return + props.onFocusedCommentChange?.(null) + }) + }) + return + } + + untrack(() => { + setStore("opened", focus.id) + + const comment = (props.comments ?? []).find((item) => item.file === focus.file && item.id === focus.id) + if (comment) setStore("selection", cloneSelectedLineRange(comment.selection)) + + // The diff renders asynchronously, so poll for the comment anchor before + // scrolling; clear the focus once handled so revisiting the file does not + // re-open a stale comment (mirrors the v1 review behavior). + focusToken++ + const token = focusToken + const scrollTo = (attempt: number) => { + if (token !== focusToken) return + const anchor = scrollRef?.querySelector(`[data-comment-id="${focus.id}"]`) + if (anchor instanceof HTMLElement) { + anchor.scrollIntoView({ block: "center" }) + return + } + if (attempt >= 120) return + requestAnimationFrame(() => scrollTo(attempt + 1)) + } + requestAnimationFrame(() => scrollTo(0)) + requestAnimationFrame(() => { + if (token !== focusToken) return + props.onFocusedCommentChange?.(null) + }) + }) + }) + + const expandUnchanged = () => props.expandMode === "expand" + + const diffViewer = () => ( + { + if (!lineCommentsEnabled()) return + commentsUi.onLineSelected(range) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!lineCommentsEnabled()) return + commentsUi.onLineSelectionEnd(range) + }} + onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderGutterUtility={lineCommentsEnabled() ? commentsUi.renderGutterUtility : undefined} + selectedLines={store.selection} + commentedLines={commentedLines()} + media={{ + mode: "auto", + path: props.file, + deleted: view().status === "deleted", + readFile: view().status === "deleted" ? undefined : props.readFile, + }} + /> + ) + + return ( + <> +
+ + +
+ {statusLabel(view().status)} +
+ + + {getFilename(props.file)} + + + + {getDirectory(props.file)} + + +
+ + + copyTextToClipboard(props.file)}>Copy full path + copyTextToClipboard(getFilename(props.file))}>Copy filename + + +
+
+ +
+
+
{ + scrollRef = el + }} + data-slot="session-review-v2-diff-scroll" + > + + {i18n.t("ui.fileMedia.binary.title")} +
+ } + > + {diffViewer()} + +
+ + ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css new file mode 100644 index 00000000..a9892989 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css @@ -0,0 +1,534 @@ +[data-component="session-review-v2"] { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--v2-background-bg-base); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-body"] { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +[data-component="session-review-v2-sidebar-root"] { + position: relative; + display: flex; + flex-shrink: 0; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"] { + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); + background: var(--v2-background-bg-base); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] { + border-right-width: 0; +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar"][data-transition]:not([data-resizing]) { + transition: + width 200ms cubic-bezier(0.22, 1, 0.36, 1), + border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-resize"] { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + z-index: 20; + width: 0; + overflow: visible; +} + +[data-component="session-review-v2-sidebar-root"] [data-component="resize-handle"]::after { + background: var(--v2-border-border-muted, var(--border-weak-base)); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-header"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 16px 12px 8px; + flex-shrink: 0; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 13px; + font-weight: 500; + color: var(--text-strong, var(--v2-text-text-base)); +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) { + margin-left: 8px; +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-title"] + [data-component="select-v2-root"] { + width: fit-content; + max-width: 100%; + min-width: 0; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] { + padding: 0 8px 4px; + flex-shrink: 0; + min-width: 0; + box-sizing: border-box; +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-filter"] + [data-component="text-input-v2"] { + width: 100%; + max-width: 100%; + min-width: 0; + margin-top: 3px; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { + flex: 1; + min-height: 0; + timeline-scope: --session-review-v2-sidebar-tree-scroll; +} + +@keyframes session-review-v2-sidebar-tree-fade-top { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes session-review-v2-sidebar-tree-fade-bottom { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before, +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + content: ""; + position: absolute; + inset-inline: 0; + z-index: 10; + height: 16px; + pointer-events: none; + visibility: hidden; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + top: 0; + background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + bottom: 0; + background: linear-gradient(to top, var(--v2-background-bg-base), transparent); +} + +@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and + (timeline-scope: --session-review-v2-sidebar-tree-scroll) { + [data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__viewport { + scroll-timeline: --session-review-v2-sidebar-tree-scroll y; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + animation: session-review-v2-sidebar-tree-fade-top linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: 0 0.1px; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + animation: session-review-v2-sidebar-tree-fade-bottom linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport { + padding: 4px 8px 12px; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { + width: 16px; +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb::after { + width: 6px; + background-color: var(--v2-border-border-muted, var(--border-weak-base)); +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb:hover::after, +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb[data-dragging="true"]::after { + background-color: var(--v2-border-border-strong, var(--border-strong-base)); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-preview"] { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar"] { + display: flex; + align-items: center; + gap: 4px; + padding: 10px 16px 10px 8px; + flex-shrink: 0; + white-space: nowrap; + border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-group"] { + display: flex; + align-items: center; + gap: 0px; + flex-shrink: 0; +} + +[data-component="session-review-v2"] .session-review-v2-toolbar-group--start { + min-width: 0; + flex-shrink: 0; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-collapsed-meta"] { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding-right: 8px; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-title"] { + display: flex; + align-items: center; + min-width: 0; + font-size: 13px; + font-weight: 500; + color: var(--text-strong, var(--v2-text-text-base)); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-title"] [data-component="select-v2-root"] { + width: fit-content; + max-width: 100%; + min-width: 0; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-position"] { + flex: none; + flex-grow: 0; + font-family: var(--v2-font-family-sans); + font-style: normal; + font-weight: 440; + font-size: 11px; + line-height: 100%; + letter-spacing: 0.05px; + text-transform: uppercase; + font-variant-numeric: tabular-nums lining-nums; + font-feature-settings: + "tnum" on, + "lnum" on; + font-variation-settings: "slnt" 0; + color: var(--v2-grey-600); + white-space: nowrap; +} + +[data-component="session-review-v2"] .session-review-v2-file-nav-button { + width: 36px; + height: 28px; + min-width: 36px; + min-height: 28px; + padding: 0; +} + +[data-component="session-review-v2"] + [data-slot="session-review-v2-toolbar-group"] + [data-component="tooltip-v2-trigger"] { + display: inline-flex; +} + +[data-component="session-review-v2"] .session-review-v2-segmented-control [data-component="tooltip-v2-trigger"] { + display: flex; + flex: 0 0 auto; + min-width: 0; +} + +[data-component="session-review-v2"] + .session-review-v2-segmented-control + [data-component="tooltip-v2-trigger"] + [data-slot="segmented-control-v2-item"] { + width: 100%; +} + +[data-component="icon-button-v2"].session-review-v2-sidebar-toggle { + width: 36px; + height: 28px; + min-width: 36px; + min-height: 28px; + border-radius: 6px; + color: var(--v2-icon-icon-base); +} + +[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] { + background-color: var(--v2-background-bg-layer-02); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-title"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] { + position: relative; + flex-shrink: 0; + margin-left: auto; + background-color: var(--v2-background-bg-base); + + &::before { + content: ""; + position: absolute; + top: -8px; + bottom: -8px; + right: 100%; + width: 16px; + z-index: 1; + pointer-events: none; + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + + &::after { + content: ""; + position: absolute; + inset: -8px -16px -8px 0; + z-index: 0; + pointer-events: none; + background-color: var(--v2-background-bg-base); + } + + [data-component="diff-changes"] { + position: relative; + z-index: 1; + } +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + font-size: 11px; + font-weight: 530; + line-height: 1; + letter-spacing: -0.04px; + text-transform: uppercase; + font-variant-numeric: tabular-nums lining-nums; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"][data-type="added"] { + color: var(--icon-diff-add-base, var(--v2-state-fg-success)); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"][data-type="deleted"] { + color: var(--icon-diff-delete-base, var(--v2-state-fg-danger)); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"][data-type="modified"] { + color: var(--v2-state-fg-info); +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-title"] [data-component="tooltip-v2-trigger"] { + display: inline-flex; + min-width: 0; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-title"] [data-component="tooltip-v2-trigger"]:has([data-slot="session-review-v2-file-path"]) { + flex: 1; + min-width: 0; + overflow: hidden; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-name"] { + flex-shrink: 0; + font-size: 13px; + font-weight: 530; + line-height: 1; + letter-spacing: -0.04px; + font-variant-numeric: tabular-nums lining-nums; + color: var(--v2-text-text-base); + white-space: nowrap; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-file-path"] { + flex: 1; + min-width: 0; + font-size: 13px; + font-weight: 440; + line-height: 1; + letter-spacing: -0.04px; + font-variant-numeric: tabular-nums lining-nums; + color: var(--v2-text-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-diff-scroll"] { + flex: 1; + min-height: 0; + overflow: auto; + --line-comment-z: 5; + --line-comment-popover-z: 30; + --line-comment-open-z: 6; +} + +[data-component="session-review-v2"] .session-review-v2-toolbar-group--segments { + gap: 12px; +} + +[data-component="session-review-v2"] .session-review-v2-toolbar-group--segments .session-review-v2-segmented-control { + width: auto; +} + +[data-component="session-review-v2"] + .session-review-v2-toolbar-group--segments + .session-review-v2-segmented-control--icon + [data-slot="segmented-control-v2-item"] { + flex: 0 0 auto; +} + +[data-component="session-review-v2"] [data-slot="session-review-v2-empty"] { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + padding-bottom: 160px; +} + +[data-slot="session-review-v2-empty-no-git"] { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; + padding-bottom: 160px; + text-align: center; +} + +[data-slot="session-review-v2-empty-no-git"] [data-component="file-icon"] { + width: 20px; + height: 20px; + color: var(--v2-icon-icon-muted); +} + +[data-slot="session-review-v2-empty-no-git-title"] { + flex: none; + font-size: 13px; + font-weight: 530; + line-height: 100%; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); +} + +[data-slot="session-review-v2-empty-no-git-description"] { + flex: none; + margin: 0 0 4px; + max-width: 360px; + font-size: 13px; + font-weight: 440; + line-height: 20px; + text-align: center; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); +} + +[data-slot="session-review-v2-empty-changes"] { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; + padding-bottom: 160px; + text-align: center; +} + +[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] { + flex: none; + color: var(--v2-icon-icon-muted); +} + +[data-slot="session-review-v2-empty-changes-title"] { + flex: none; + margin-top: 4px; + font-size: 13px; + font-weight: 530; + line-height: 100%; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); +} + +[data-slot="session-review-v2-empty-changes-description"] { + flex: none; + max-width: 282px; + font-size: 13px; + font-weight: 440; + line-height: 20px; + text-align: center; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx new file mode 100644 index 00000000..c2699119 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -0,0 +1,323 @@ +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { SessionReviewDiffStyle } from "../../components/session-review" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { makeEventListener } from "@solid-primitives/event-listener" +import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js" +import { getWorkerPool } from "../../pierre/worker" +import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2" + +export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240 +export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200 +export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX = 480 + +export type SessionReviewExpandMode = "expand" | "collapse" + +export type SessionReviewV2Props = { + title?: JSX.Element + stats?: JSX.Element + empty?: JSX.Element + sidebarOpen?: boolean + sidebar?: JSX.Element + activeFile?: string + files: string[] + onSelectFile: (file: string) => void + diffStyle: SessionReviewDiffStyle + onDiffStyleChange?: (style: SessionReviewDiffStyle) => void + expandMode: SessionReviewExpandMode + onExpandModeChange: (mode: SessionReviewExpandMode) => void + preview?: JSX.Element + hasDiffs: boolean +} + +export type SessionReviewV2SidebarProps = { + open: boolean + transition: boolean + title?: JSX.Element + stats?: JSX.Element + filter: string + onFilterChange: (value: string) => void + onFilterKeyDown?: JSX.EventHandlerUnion + filterAutofocus?: boolean + filterRef?: (element: HTMLInputElement) => void + filterControls?: string + filterActiveDescendant?: string + filterExpanded?: boolean + width?: number + onWidthChange?: (width: number) => void + minWidth?: number + maxWidth?: number + viewportRef?: (element: HTMLDivElement) => void + children?: JSX.Element +} + +export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { + const i18n = useI18n() + const [resizing, setResizing] = createSignal(false) + const width = () => props.width ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT + const minWidth = () => props.minWidth ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN + const maxWidth = () => props.maxWidth ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX + + createEffect(() => { + if (!resizing()) return + const stop = () => setResizing(false) + makeEventListener(document, "pointerup", stop) + makeEventListener(document, "pointercancel", stop) + }) + + return ( +
+ + + + +
setResizing(true)}> + props.onWidthChange?.(next)} + onDblClick={() => props.onWidthChange?.(SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT)} + /> +
+
+
+ ) +} + +export function SessionReviewV2(props: SessionReviewV2Props) { + const i18n = useI18n() + + createEffect(() => { + getWorkerPool(props.diffStyle) + }) + + const fileIndex = () => { + const files = props.files + if (files.length === 0) return -1 + + const active = props.activeFile + const i = active ? files.indexOf(active) : -1 + if (i >= 0) return i + return 0 + } + + const prev = () => { + if (!canCycle()) return + return props.files[(fileIndex() - 1 + props.files.length) % props.files.length] + } + + const next = () => { + if (!canCycle()) return + return props.files[(fileIndex() + 1) % props.files.length] + } + + const canCycle = () => props.files.length > 0 + const showCollapsedMeta = () => props.sidebarOpen === false + // Memoize slot getters so Show conditions do not instantiate throwaway elements. + const title = createMemo(() => props.title) + const stats = createMemo(() => props.stats) + + const cycle = (file: string | undefined) => { + if (!file) return + props.onSelectFile(file) + } + + // Keep the advertised arrow keys working while the + // pane is mounted, but never while typing in an input or comment editor. + makeEventListener(document, "keydown", (event) => { + if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return + const target = event.target + if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return + if (!props.hasDiffs || !canCycle()) return + const file = event.key === "ArrowLeft" ? prev() : next() + if (!file) return + event.preventDefault() + cycle(file) + }) + + const toolbarStart = () => ( + <> + +
+ +
{title()}
+
+ {stats()} + + + {fileIndex() + 1}/{props.files.length} + + +
+
+
+ + {i18n.t("ui.sessionReviewV2.previousFile")} + + + } + > + cycle(prev())} + aria-label={i18n.t("ui.sessionReviewV2.previousFile")} + /> + + + {i18n.t("ui.sessionReviewV2.nextFile")} + + + } + > + cycle(next())} + aria-label={i18n.t("ui.sessionReviewV2.nextFile")} + /> + +
+ + ) + + const toolbarEnd = () => ( + <> + + { + if (value !== "unified" && value !== "split") return + props.onDiffStyleChange?.(value) + }} + class="session-review-v2-segmented-control session-review-v2-segmented-control--icon" + aria-label={i18n.t("ui.sessionReviewV2.diffView")} + > + + + + + + + + + + + + + + ) + + return ( + + + {props.empty}}> + {props.preview} + + + + ) +} + +export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) { + const i18n = useI18n() + + return ( + + } + /> + + ) +} diff --git a/packages/app-bundle/scripts/extract_overlay.mjs b/packages/app-bundle/scripts/extract_overlay.mjs index e0ea2ec6..5dfc0053 100644 --- a/packages/app-bundle/scripts/extract_overlay.mjs +++ b/packages/app-bundle/scripts/extract_overlay.mjs @@ -1,22 +1,40 @@ #!/usr/bin/env node -// M2 slice (a) overlay extractor (#451) — extracts the app-bundle overlay from -// the opencode fork at a pinned tag into packages/app-bundle/overlay/, at +// M2 overlay extractor (#451) — extracts the app-bundle overlay from the +// opencode fork at a pinned tag into packages/app-bundle/overlay/, at // UPSTREAM-RELATIVE paths, and writes manifest.json (per-file sha256 + the // machine-derived A/M/D classification against the upstream base). // -// node scripts/extract_overlay.mjs [--fork ] [--tag v1.18.10-amicode.14] +// node scripts/extract_overlay.mjs [--fork ] [--tag v1.18.10-amicode.14] [--slice a|ab] // // The fork clone defaults to the sibling checkout (team layout: // ~/armonia/repos/opencode). Extraction is deterministic: same tag → // identical bytes → identical hashes (files are read via git archive AT the // tag, never the working tree). // -// SLICE (a) SCOPE — the COMPLETE packages/ui delta: -// overlay = every file under packages/ui that is Added or Modified vs the -// upstream base. Complete by construction: materialize(upstream@base, -// overlay) ≡ fork@tag packages/ui, byte for byte. The whole-directory -// classification (A/M/D per file) is recorded in the manifest — the -// machine-derived inventory for later decomposition slices. +// SCOPE (slice b, corrected 2026-08-21): the COMPLETE fork-vs-base delta of +// the app's build graph — packages/{app,ui,session-ui,schema,core,sdk}. +// +// The additive-only scope this slice was planned around does not typecheck: +// ~10 additive app files depend on SYMBOL-LEVEL additions in modified files +// (settings.developer, tabs.openPath, model.pin, QuestionInfo.kind — the +// typed question cards spanning schema/core/sdk), and the fork's debug-bar +// deletion forces layout.tsx into the overlay (base layout imports the +// deleted module). The fork delta is a cross-cutting FEATURE delta, not an +// app-layer delta. The complete-graph overlay is the honest scope: +// +// - equivalence is trivial (byte-identical per package) and +// - composition (install + typecheck + build) proves the bundle buildable +// against a fork-equivalent tree TODAY. +// +// The manifest's per-package classification + the server-coupled files +// (schema/core/sdk — types & runtime the CANONICAL server will not have) +// are the machine-derived M3 CUTOVER PORT INVENTORY: every feature that +// spans the app and the fork's server packages is on it, because the bundle +// will hit exactly these gaps against canonical at cutover. +// +// The compose-vs-fork decomposition of the true overlays (home.tsx etc.) +// remains deliberate POST-cutover maintenance work — wholesale overlay +// inclusion is correct for the one-push cutover this plan ships. // // BASE: v1.18.12 — tree-equal to the fork's true merge base (b0b114923, // v1.18.12~1) except the tag commit's version-string bumps, which land in @@ -25,9 +43,9 @@ // the fork's package.json is that merge's residue, not a later sync. import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, rmSync, statSync, readdirSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, normalize } from "node:path"; const PKG_ROOT = join(import.meta.dirname, ".."); const FORK_DEFAULT = join(homedir(), "armonia", "repos", "opencode"); @@ -40,72 +58,138 @@ const flag = (n) => { const UPSTREAM_BASE = "v1.18.12"; const TAG = flag("tag") ?? "v1.18.10-amicode.14"; +const SLICE = flag("slice") ?? "full"; const FORK = flag("fork") ?? process.env.AMICODE_OPENCODE_SRC ?? FORK_DEFAULT; const git = (...a) => execFileSync("git", ["-C", FORK, ...a], { encoding: "utf8" }).trim(); -const sha256Buf = (b) => createHash("sha256").update(b).digest("hex"); +const gitAt = (spec, maxBuffer = 1 << 26) => + execFileSync("git", ["-C", FORK, "show", spec], { maxBuffer }); -// ── 1. resolve the tag + compute the complete packages/ui delta ─────────────── +// ── 1. resolve the tag + compute the deltas ─────────────────────────────────── const tagSha = git("rev-parse", `${TAG}^{commit}`); const baseSha = git("rev-parse", `${UPSTREAM_BASE}^{commit}`); -console.log(`[extract] fork ${FORK} @ ${TAG} (${tagSha.slice(0, 10)}), upstream base ${UPSTREAM_BASE} (${baseSha.slice(0, 10)})`); +console.log(`[extract] fork ${FORK} @ ${TAG} (${tagSha.slice(0, 10)}), upstream base ${UPSTREAM_BASE} (${baseSha.slice(0, 10)}), slice ${SLICE}`); + +function diffStatus(paths) { + return git("diff", "--name-status", UPSTREAM_BASE, TAG, "--", ...paths) + .split("\n") + .filter(Boolean) + .map((line) => { + const [status, ...rest] = line.split("\t"); + return { status, path: rest[rest.length - 1] }; + }); +} + +const GRAPH_PKGS = ["packages/app", "packages/ui", "packages/session-ui", "packages/schema", "packages/core", "packages/sdk"]; +const SERVER_COUPLED_PKGS = ["packages/schema", "packages/core", "packages/sdk"]; + +const graphDelta = diffStatus(GRAPH_PKGS); + +const uiOverlay = graphDelta.filter((d) => d.path.startsWith("packages/ui/") && d.status !== "D").map((d) => d.path); +const appSessionAdds = graphDelta.filter((d) => !SERVER_COUPLED_PKGS.some((p) => d.path.startsWith(p + "/")) && d.path.startsWith("packages/") && !d.path.startsWith("packages/ui/") && d.status === "A").map((d) => d.path); +const appSessionMods = graphDelta.filter((d) => !SERVER_COUPLED_PKGS.some((p) => d.path.startsWith(p + "/")) && !d.path.startsWith("packages/ui/") && d.status === "M").map((d) => d.path); +const appSessionDels = graphDelta.filter((d) => d.status === "D").map((d) => d.path); +const serverCoupled = graphDelta.filter((d) => SERVER_COUPLED_PKGS.some((p) => d.path.startsWith(p + "/")) && d.status !== "D").map((d) => d.path); -const delta = git("diff", "--name-status", UPSTREAM_BASE, TAG, "--", "packages/ui") - .split("\n") - .filter(Boolean) - .map((line) => { - const [status, ...rest] = line.split("\t"); - return { status, path: rest[rest.length - 1] }; - }); -const adds = delta.filter((d) => d.status === "A").map((d) => d.path); -const mods = delta.filter((d) => d.status === "M").map((d) => d.path); -const dels = delta.filter((d) => d.status === "D").map((d) => d.path); -const overlayFiles = [...adds, ...mods].sort(); -console.log(`[extract] packages/ui delta vs ${UPSTREAM_BASE}: ${adds.length} added, ${mods.length} modified, ${dels.length} deleted → overlay of ${overlayFiles.length} files`); +// ── 2. server-coupled files (schema/core/sdk): part of the overlay (the +// bundle typechecks against them today), AND the M3 cutover port inventory +// (they carry types/runtime the canonical server will NOT have at cutover). ── -// ── 2. extract overlay files at their upstream-relative paths, AT THE TAG ──── +// ── 3. the overlay set ─────────────────────────────────────────────────────── +const overlayFiles = [...new Set([...graphDelta.filter((d) => d.status !== "D").map((d) => d.path)])].sort(); +const classification = {}; +for (const d of graphDelta) if (d.status !== "D") classification[d.path] = d.status; +const deletions = graphDelta.filter((d) => d.status === "D").map((d) => d.path); + +const perPkg = {}; +for (const d of graphDelta) { + const pkg = d.path.split("/").slice(0, 2).join("/"); + perPkg[pkg] ??= { A: 0, M: 0, D: 0 }; + perPkg[pkg][d.status] += 1; +} +console.log("[extract] per-package delta:", JSON.stringify(perPkg)); +console.log( + `[extract] app+session-ui overlays: ${appSessionAdds.length} A + ${appSessionMods.length} M (complete); server-coupled (schema/core/sdk): ${serverCoupled.length} files — the M3 cutover port inventory`, +); +console.log(`[extract] OVERLAY TOTAL: ${overlayFiles.length} files, ${deletions.length} deletions`); + +// ── 4. extract at their upstream-relative paths, AT THE TAG ────────────────── const overlayDir = join(PKG_ROOT, "overlay"); rmSync(overlayDir, { recursive: true, force: true }); mkdirSync(overlayDir, { recursive: true }); -execFileSync("sh", ["-c", `git -C '${FORK}' archive --format=tar ${TAG} ${overlayFiles.map((f) => `'${f}'`).join(" ")} | tar -x -C '${overlayDir}'`]); +if (overlayFiles.length > 0) { + // git archive arg list can get long — batch it. + for (let i = 0; i < overlayFiles.length; i += 100) { + const batch = overlayFiles.slice(i, i + 100); + execFileSync("sh", [ + "-c", + `git -C '${FORK}' archive --format=tar ${TAG} ${batch.map((f) => `'${f}'`).join(" ")} | tar -x -C '${overlayDir}'`, + ]); + } +} -// ── 3. manifest: hashes + classification + round-trip proof ───────────────── +// ── 5. manifest: hashes + classification + round-trip proof ───────────────── +const sha256Buf = (b) => createHash("sha256").update(b).digest("hex"); const hashes = {}; -const classification = {}; let mismatches = 0; for (const rel of overlayFiles) { const p = join(overlayDir, rel); - if (!statSync(p).isFile()) throw new Error(`extractor bug: ${rel} not a file after archive`); - const blob = execFileSync("git", ["-C", FORK, "show", `${TAG}:${rel}`], { maxBuffer: 1 << 26 }); + const st = lstatSync(p); + if (st.isSymbolicLink()) { + // Symlinks: git archive materializes them as real symlinks; `git show` + // prints the TARGET PATH, not the content. The round-trip proof for a + // symlink is readlink === the git blob's content. + const link = readlinkSync(p); + const blob = gitAt(`${TAG}:${rel}`).toString("utf8").trimEnd(); + if (link !== blob) { + console.error(`[extract] ROUND-TRIP MISMATCH (symlink): ${rel}: ${link} != ${blob}`); + mismatches++; + } + // hash the LINK TARGET STRING (the stable, content-independent identity) + hashes[rel] = createHash("sha256").update(link).digest("hex"); + continue; + } + if (!st.isFile()) throw new Error(`extractor bug: ${rel} not a regular file after archive`); + const blob = gitAt(`${TAG}:${rel}`); const h = sha256Buf(readFileSync(p)); if (h !== sha256Buf(blob)) { console.error(`[extract] ROUND-TRIP MISMATCH: ${rel}`); mismatches++; } hashes[rel] = h; - classification[rel] = adds.includes(rel) ? "A" : "M"; } if (mismatches > 0) process.exit(1); console.log(`[extract] round-trip: all ${overlayFiles.length} files byte-identical to ${TAG}`); -// Unchanged files (sanity: ui files at tag not in the overlay come from the base) -const uiAtTag = git("ls-tree", "-r", "--name-only", TAG, "--", "packages/ui").split("\n").filter(Boolean); -const unchanged = uiAtTag.filter((f) => !overlayFiles.includes(f) && !dels.includes(f)); -console.log(`[extract] ui files at tag: ${uiAtTag.length} = ${overlayFiles.length} overlay + ${unchanged.length} base-unchanged + ${dels.length} deleted`); - const manifest = { - schema: 2, - slice: "a", - scope: "packages/ui (complete delta)", + schema: 4, + slices: "b (complete app-graph delta)", + scope: "complete fork-vs-base delta of packages/{app,ui,session-ui,schema,core,sdk}", fork_tag: TAG, fork_sha: tagSha, upstream_base: UPSTREAM_BASE, upstream_base_sha: baseSha, extracted_at: new Date().toISOString(), - counts: { added: adds.length, modified: mods.length, deleted: dels.length, unchanged: unchanged.length }, - deletions: dels, + per_package: perPkg, + counts: { + overlay_total: overlayFiles.length, + deletions: deletions.length, + server_coupled: serverCoupled.length, + }, + // The M3 CUTOVER PORT INVENTORY: schema/core/sdk files whose types or + // runtime the canonical server will NOT have. Every app feature touching + // these is a cutover blocker to port upstream, reimplement in the + // extension service, or drop from the bundle. + server_coupled_port_inventory: serverCoupled.sort(), + // The true overlays (big M-files in app/ui upstream still evolves), + // recorded for the POST-cutover compose-vs-fork decomposition. + true_overlays: graphDelta + .filter((d) => d.status === "M" && !SERVER_COUPLED_PKGS.some((p) => d.path.startsWith(p + "/"))) + .map((d) => d.path) + .sort(), + deletions, classification, files: hashes, }; writeFileSync(join(PKG_ROOT, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); -console.log(`[extract] wrote manifest.json (${overlayFiles.length} entries, ${dels.length} deletions)`); +console.log(`[extract] wrote manifest.json (${overlayFiles.length} entries, ${deletions.length} deletions)`); diff --git a/packages/app-bundle/scripts/materialize.mjs b/packages/app-bundle/scripts/materialize.mjs index 7965bac6..a07a8438 100644 --- a/packages/app-bundle/scripts/materialize.mjs +++ b/packages/app-bundle/scripts/materialize.mjs @@ -10,8 +10,9 @@ // upstream paths (bucket M) or add new ones (bucket A) — copy is the whole // conflict policy for slice (a); the drift report is a separate concern. import { createHash } from "node:crypto"; +import { readlinkSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, readdirSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, readdirSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -81,9 +82,10 @@ for (const rel of readdirSync(overlayDir, { recursive: true })) { const p = join(overlayDir, rel.toString()); if (!statSync(p).isFile()) continue; const dest = join(outDir, rel.toString()); - if (existsSync(dest)) overwritten++; + if (existsSync(dest) || lstatSync(dest, { throwIfNoEntry: false })) overwritten++; mkdirSync(join(dest, ".."), { recursive: true }); - cpSync(p, dest); + // verbatimSymlinks: preserve overlay symlinks AS symlinks (amico.svg → ui asset) + cpSync(p, dest, { verbatimSymlinks: true }); applied++; } console.log(`[materialize] tree at ${outDir}: upstream ${repo}@${tag} + overlay (${applied} files applied, ${overwritten} overwrote upstream)`); @@ -104,7 +106,22 @@ const sha256 = (p) => createHash("sha256").update(readFileSync(p)).digest("hex") let bad = 0; for (const [rel, want] of Object.entries(manifest.files)) { const p = join(outDir, rel); - if (!existsSync(p) || sha256(p) !== want) { + const st = lstatSync(p, { throwIfNoEntry: false }); + if (!st) { + console.error(`[materialize] MANIFEST MISMATCH (missing): ${rel}`); + bad++; + continue; + } + if (st.isSymbolicLink()) { + // symlink identity = the link-target string (see extract_overlay.mjs) + const h = createHash("sha256").update(readlinkSync(p)).digest("hex"); + if (h !== want) { + console.error(`[materialize] MANIFEST MISMATCH (symlink): ${rel}`); + bad++; + } + continue; + } + if (sha256(p) !== want) { console.error(`[materialize] MANIFEST MISMATCH: ${rel}`); bad++; }