From dd81e18af4adb7dafedf96fa299283319b4dd707 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 17:28:41 -0400 Subject: [PATCH 1/8] docs: define dockable Work Column --- CONTEXT.md | 12 ++++++++++++ docs/adr/0014-dockable-work-column.md | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 docs/adr/0014-dockable-work-column.md diff --git a/CONTEXT.md b/CONTEXT.md index edb7344b..d9c51841 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -126,6 +126,18 @@ _Avoid_: port forward (as a concept name), launchd tunnel ### Surfaces +**Work Column**: +The session-scoped auxiliary surface beside Chat that contains Home, Files Changed, Context, Pulse Inspector, and Preview. On desktop it may dock right, left, or bottom of Chat or move into a paired panel-only VS Code editor tab; every host presents the same logical state and features. +_Avoid_: Side panel, sidebar, editor + +**Detached Work Column**: +The panel-only VS Code editor-tab host for a Work Column record. It is fully connected to its bound Chat and session, owns the record's live lease while detached, and exposes the same features as an attached Work Column; it is never a copied view or a separate Chat. +_Avoid_: Floating chat, copied panel, separate chat + +**Suspended Work Column**: +A durable Work Column record with no live host after its source Chat and detached panel have closed. A matching Chat may recover it without losing acknowledged state. +_Avoid_: Discarded panel, orphaned window + **Home**: The always-present first tab in the Work Column that renders the user's widget grid — profile cards, run status, problem summaries, and custom agent-authored widgets. The single canonical surface for widgets; replaces the standalone home page. Internally powered by the widget kernel (WidgetGrid, WidgetFrame, the bridge protocol, `/amicode/widgets` + `/amicode/dashboard` endpoints). _Avoid_: Dashboard (as the surface name), widget panel diff --git a/docs/adr/0014-dockable-work-column.md b/docs/adr/0014-dockable-work-column.md new file mode 100644 index 00000000..99a02be8 --- /dev/null +++ b/docs/adr/0014-dockable-work-column.md @@ -0,0 +1,24 @@ +# 0014 - Make the Work Column dockable and transactionally detachable + +Status: proposed (2026-09-10) + +Tracking: harmoniqs/amicode#977 + +The Work Column remains the app-owned auxiliary surface, but its desktop host may dock right, left, or bottom of Chat or move into a paired panel-only VS Code editor tab. Attached and detached modes are exclusive hosts for one durable Work Column record. They provide the same features and logical state, including Preview editing, rather than presenting copied or degraded views. + +## Decision + +Use an extension-owned, durable Work Column journal with generation-fenced host leases. Every transfer-relevant mutation is write-ahead journaled and acknowledged before the host treats it as committed. The attached host drains to a checksummed checkpoint, the destination hydrates read-only, and the durable lease compare-and-swaps to the destination only after an exact acknowledgement. Reattachment is the same transaction in reverse. Native panel disposal restores from the journal rather than attempting to extract state after close. + +The source Chat is chat-only while detached. Its Side Panel control reports the external state and reveals the paired editor tab. If the source closes, the detached panel closes after its final checkpoint and the record becomes suspended and recoverable. No host closure discards the record by itself. + +## Considered Options + +1. **Durable record with transactional handoff** -- chosen. It is the only option that can preserve full functional parity and avoid drift across disposal, reload, and host failure. +2. **One-shot snapshot or renderer reparenting** -- rejected. DOM nodes, editor instances, PDF tasks, iframe runtimes, and post-close state cannot cross a webview boundary safely. +3. **Read-only detached Preview or detach refusal for dirty state** -- rejected. The detached Work Column must work exactly as attached mode, including editing. +4. **Extension-owned duplicate renderer** -- rejected. It would create two mutable implementations and a permanent state-drift risk. + +## Consequences + +The journal must carry serializable Preview state, editor undo/redo, pending save and close operations, widget snapshots and subscriptions, inspector sequence/replay buffers, and all relevant Work Column UI state. Widgets gain a suspend/resume contract. Cross-webview transfer is a deliberate exception to ADR 0013's in-document retained-renderer rule: normal tab moves preserve physical renderer identity, while detached moves preserve logical renderer state through checkpoint and hydration. The eight-document cap remains logical, although a transaction may briefly create source and destination renderers. Every durable record has a canonical codec version, checksum, owner identity, generation, and compare-and-swap lease predecessor so stale hosts cannot write. From 8bd38bb6fb7e40ac7ddaf65b158153f2d4a3df1b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 17:45:43 -0400 Subject: [PATCH 2/8] docs: clarify Preview cross-webview ownership --- docs/adr/0013-preview-workspace-split-panes.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/adr/0013-preview-workspace-split-panes.md b/docs/adr/0013-preview-workspace-split-panes.md index 608cc3e2..f578c8d5 100644 --- a/docs/adr/0013-preview-workspace-split-panes.md +++ b/docs/adr/0013-preview-workspace-split-panes.md @@ -15,3 +15,9 @@ Preview — previously a single-file companion viewer that replaced its content **Considered:** (A) Extend `SessionPreviewTab` in place with tab/breadcrumb/split logic (rejected: the component is 119 lines today, but tab-bar, pane-tree, and breadcrumb logic are three distinct responsibilities that would tangle together as each grows independently — not a current-size problem but a projected-shape one); (B) **new `PreviewWorkspace`/`PreviewPane`/`PreviewBreadcrumb` component tree** (chosen: clean separation, each component independently testable, `SessionPreviewTab` shrinks to a thin shell); (C) a generic `SplitPaneLayout` primitive built first and specialized for Preview (rejected for now: speculative reuse — no other surface has asked for splitting yet — and the abstraction would be guessed at rather than derived from a second real use; extracting it from B later is straightforward if that need materializes). **Flip condition:** If a second surface (e.g. Files Changed) independently needs split-pane viewing, extract the pane-tree logic from `PreviewWorkspace` into the generic primitive considered as option C, rather than duplicating the tree/drag machinery. If the side panel's minimum width increases substantially in a future layout pass, revisit the 150px pane minimum and how many practical splits it should allow. + +## Amendment: Cross-Webview Ownership (2026-09-10) + +The ownership implied above is made explicit for detachable Work Column hosts. A Preview leaf owns tab order, active tab, focus, split geometry, and zoom. A Preview document owns mode, draft, selections, editor history, pending save or close state, conflict state, and renderer scroll. The workspace owns outer-canvas scroll. + +Ordinary movement within one document preserves physical renderer identity. Cross-webview Work Column transfer is a separate transactional path: it preserves the same logical document state through a versioned checkpoint and hydration, but necessarily creates a new physical renderer instance. File reads and writes use content revisions so a restored draft cannot be silently overwritten by a late read or stale save result. From 573262e20d52c6549b1b3e209a2444b649781262 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 17:49:33 -0400 Subject: [PATCH 3/8] docs: clarify Preview state ownership --- docs/adr/0013-preview-workspace-split-panes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0013-preview-workspace-split-panes.md b/docs/adr/0013-preview-workspace-split-panes.md index f578c8d5..03211c0c 100644 --- a/docs/adr/0013-preview-workspace-split-panes.md +++ b/docs/adr/0013-preview-workspace-split-panes.md @@ -8,7 +8,7 @@ Preview — previously a single-file companion viewer that replaced its content **Why:** The single-file companion model (#931) optimized for a different problem — one file at a time, driven externally, no navigation chrome competing with the Sidebar. In practice, researchers comparing two files (a script and its output, a spec and its implementation) lost their place every time a second file replaced the first. The companion model traded away exactly the capability multi-file work needs. Sidebar remains the project-wide file tree; the breadcrumb is a narrower, contextual navigation aid scoped to the currently open file's siblings, not a second file browser. -**Conditions of acceptance:** Files opened via Sidebar single-click or a Chat file pill accumulate as inner tabs in the focused pane rather than replacing the current file; re-opening an already-open file (from any entry point, including breadcrumb sibling navigation) focuses its existing tab instead of duplicating it — enforced workspace-wide, not just within one pane. Tabs close via an explicit control and are drag-reorderable. A breadcrumb bar shows the active file's project-relative path as clickable segments, each expanding to a sibling dropdown. Dragging a tab to a pane's edge splits that pane (horizontal or vertical); splits are recursive, subject to a 150px minimum pane dimension that refuses drops which would violate it and clamps resizes at the same floor. Each pane carries independent zoom and preview/edit-toggle state. The entire workspace — every pane, every tab, all per-tab state including unsaved edits — survives switching to another outer side-panel tab and back. Double-click-to-open-in-VS-Code and the outer tab bar are unchanged. +**Conditions of acceptance:** Files opened via Sidebar single-click or a Chat file pill accumulate as inner tabs in the focused pane rather than replacing the current file; re-opening an already-open file (from any entry point, including breadcrumb sibling navigation) focuses its existing tab instead of duplicating it — enforced workspace-wide, not just within one pane. Tabs close via an explicit control and are drag-reorderable. A breadcrumb bar shows the active file's project-relative path as clickable segments, each expanding to a sibling dropdown. Dragging a tab to a pane's edge splits that pane (horizontal or vertical); splits are recursive, subject to a 150px minimum pane dimension that refuses drops which would violate it and clamps resizes at the same floor. Each pane carries independent zoom; each document carries its own preview/edit-toggle state. The entire workspace — every pane, every tab, all per-tab state including unsaved edits — survives switching to another outer side-panel tab and back. Double-click-to-open-in-VS-Code and the outer tab bar are unchanged. **Accepted costs:** The workspace reintroduces navigation surface (the breadcrumb) that #931 deliberately removed — a future reader of #931's history will see this as a partial reversal, not a straight line; the ADR exists so that reversal reads as deliberate. Per-pane state (zoom, mode, scroll, unsaved content) roughly doubles or triples the state `SessionPreviewTab` used to hold for a single file, now split across `PreviewWorkspace`/`PreviewPane`. The workspace store must be lifted into the layout context (above `session-side-panel.tsx`'s `` gate) rather than owned locally, because SolidJS disposes a `` branch's reactive scope on every toggle — the single-file `SessionPreviewTab` already loses its local state this way today, and a multi-file, multi-pane workspace makes that loss far more costly if inherited unfixed. At the 330px minimum panel width (`WORK_COLUMN_WIDTH_MIN`), only one horizontal split is practically usable before panes drop below a comfortable reading width — accepted as a constraint of the side-panel form factor. Splitting, cross-pane tab transfer, and pane resizing are pointer/drag-only in this version; there is no keyboard-accessible path for any of the three. @@ -18,6 +18,6 @@ Preview — previously a single-file companion viewer that replaced its content ## Amendment: Cross-Webview Ownership (2026-09-10) -The ownership implied above is made explicit for detachable Work Column hosts. A Preview leaf owns tab order, active tab, focus, split geometry, and zoom. A Preview document owns mode, draft, selections, editor history, pending save or close state, conflict state, and renderer scroll. The workspace owns outer-canvas scroll. +The ownership implied above is made explicit for detachable Work Column hosts. The workspace owns the pane tree, including split-node direction, ratio, and child structure, plus outer-canvas scroll. A Preview leaf owns tab order, active tab, focus, and zoom. A Preview document owns mode, draft, selections, editor history, pending save or close state, conflict state, and renderer scroll. Ordinary movement within one document preserves physical renderer identity. Cross-webview Work Column transfer is a separate transactional path: it preserves the same logical document state through a versioned checkpoint and hydration, but necessarily creates a new physical renderer instance. File reads and writes use content revisions so a restored draft cannot be silently overwritten by a late read or stale save result. From 3c9ee976efeee384280167aacadbb62e2c840a83 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 19:35:34 -0400 Subject: [PATCH 4/8] chore(app-bundle): sync preview interaction overlay --- packages/app-bundle/manifest.json | 40 +- .../overlay/packages/app/src/app.tsx | 12 +- .../app/src/components/prompt-input-v2.tsx | 13 +- .../components/session/pdf-canvas-view.tsx | 202 ++++- .../components/session/preview-file-view.tsx | 221 +++-- .../session/session-preview-tab.tsx | 749 +++++++++++++++-- .../packages/app/src/context/layout.tsx | 26 + .../packages/app/src/design-polish.css | 436 ++++++++-- .../overlay/packages/app/src/index.css | 18 + .../packages/app/src/pages/new-session.tsx | 36 +- .../app/src/pages/session/helpers.test.ts | 35 +- .../packages/app/src/pages/session/helpers.ts | 14 +- .../session-side-panel-structure.test.ts | 6 + .../src/pages/session/session-side-panel.tsx | 756 +++++++++++------- .../session/timeline/message-timeline.tsx | 44 +- .../session-ui/src/components/markdown.css | 55 -- .../src/components/message-part.css | 212 +---- .../src/components/message-part.tsx | 237 +----- .../packages/ui/src/amicode/shell-row.test.ts | 26 +- .../packages/ui/src/amicode/shell-row.ts | 28 - .../ui/src/components/amicode-shell-row.tsx | 2 +- 21 files changed, 2015 insertions(+), 1153 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 29b2caeb..30b5610f 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -977,10 +977,10 @@ "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", "packages/app/public/amico.svg": "a14b9d543d895bcdf0758f7b9ef5908ee0acaac794446494af059b159247db8f", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", - "packages/app/src/app.tsx": "a72e7cef35d5de80980fbb1fc26c14d8551d1677821e72798c624842927b55fd", - "packages/app/src/design-polish.css": "5c86194cadbb6de279920f99254f82a175d26650041ff9c32fdc8fdd2eaaa75f", + "packages/app/src/app.tsx": "91ea1817db2f7e21caae642f3c2e276b4835fcecf499699cb787e7a5521ae20b", + "packages/app/src/design-polish.css": "bd13b37fcd73077ce9b527aa2207967430715056d133d6fb888bf9b7c97d53b1", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", - "packages/app/src/index.css": "08179e06ce2d419a2d98acc96025f91c7709062ea9f3ad245e88dc35e75ff9f7", + "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/core/src/config.ts": "0b6b81bea6a3a09285daa4bd70757e6bbe93cf2508121e1062b71bb24654d692", "packages/core/src/location-mutation.ts": "5fa852c48e98cec513346f848b422c2d5d6d4b753b39da851c73be073a03511a", @@ -1045,7 +1045,7 @@ "packages/app/src/components/posture-indicator.ts": "f546413e0e1f8296a7a444d1c07da724d67a160657888273110d06cb6789933d", "packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8", "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da", - "packages/app/src/components/prompt-input-v2.tsx": "841ecc9b38ecf8e312f6f62f1ffbca391f9d0c0ee68aedd0329ac98333edab60", + "packages/app/src/components/prompt-input-v2.tsx": "5f15ed441bc267cbdf9ef211bced384d7d9bb3c0b2fffb6a1020b34584a4f5ce", "packages/app/src/components/prompt-input.tsx": "1bc71d585a5ada2caf6d190d702dcabbfb56642e740a08ad6aedf078809b60e5", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", @@ -1085,7 +1085,7 @@ "packages/app/src/context/language.tsx": "cb545e04c128bf981b2b72ac407f9220cf9e80337fe8c87482ffe85407e0cf9a", "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c", "packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5", - "packages/app/src/context/layout.tsx": "cc9dffc8ddacd825038610a9fd4783a41852853f1d2fe1bb65b4e8348db1e78d", + "packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb", "packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade", "packages/app/src/context/local-agent.ts": "0aab67e695dc3bb45a733ac0df80a0a5e14cfe29b2375b6dc3cd07fbccea33e2", "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", @@ -1131,7 +1131,7 @@ "packages/app/src/pages/layout.tsx": "a6e6574054f6d85c0d91c1236c9db99674b8d51876643896b0c34f77a28bc85a", "packages/app/src/pages/new-session-landing.test.ts": "c0f47e4096e6a20fa59dac2b10f7285d3ef45e9289bf6f0ad75c1d9e4dd1117f", "packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268", - "packages/app/src/pages/new-session.tsx": "5a9c699ab5579ab4ff14e67fa8696bf4d941c6e4ea1f9150fec6ca52c9435826", + "packages/app/src/pages/new-session.tsx": "6025f6ce97ea928acf39578d7f1aaec59b3d6a63e75dadb830859d5b543b83f0", "packages/app/src/pages/session.tsx": "7179c59d355f70959164df7285f234c990e8aa3887e3ea736ccc8478d1931863", "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f", "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", @@ -1205,7 +1205,7 @@ "packages/session-ui/src/components/markdown-shiki.worker.ts": "0f438e40aee871d3da81488e21f42c0fefcd9af4a0eec9c599cd969566c2610e", "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": "9c7954d524e62c34e5f3391dd0174da1535bf3efeffd00d2ff092f781e0439e8", + "packages/session-ui/src/components/markdown.css": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165", "packages/session-ui/src/components/markdown.tsx": "89e132e62d20c873311c9781d0ca4d7cd1a7ff882d99f19c8696aafaf4a8f298", "packages/session-ui/src/components/message-part-css.test.ts": "ffab7d2c94e8d40ca35e4a8d4383cc6efd805b133764176b70046fba874a5f06", "packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da", @@ -1213,9 +1213,9 @@ "packages/session-ui/src/components/message-part-skill.ts": "d65a15dc39b8c0dece13aebe5e308426ae2dd758b07740251525ba9257654cf7", "packages/session-ui/src/components/message-part-text.ts": "e38d68224f1743ccc5e0e0a8023e11977e3b761ec67dd5364cde9cb34e97ea4b", "packages/session-ui/src/components/message-part-user.ts": "1f2c8cb4efff5274863a133f369a7397cac804678e953740f356ae03b71d475c", - "packages/session-ui/src/components/message-part.css": "23bd2abcff0ab2993f1a23f399512fe02a3a81e748e55aa931ba6c27d1d277e7", + "packages/session-ui/src/components/message-part.css": "ae592deb33739738f6a7fc6a3460b7146803bdd05cbbf46f1cfd2c5cec421392", "packages/session-ui/src/components/message-part.test.ts": "7c66430837fa3d2607657e57e35720ae68dc5e84662e12826713a938301eeb7b", - "packages/session-ui/src/components/message-part.tsx": "1c0befaade3eb0452d09463e1a2dbe5fe158ab2e03373d18b07fe4a5726b8d02", + "packages/session-ui/src/components/message-part.tsx": "e94f064b10030622250c8e9bd0c869796cc6dda20fbcb643736c8b4802b1b2b7", "packages/session-ui/src/components/session-diff.ts": "97f1ca7f80cb316c3fd2b228a8f8d4db3a6b78d155216148e0a3198c500ac6d7", "packages/session-ui/src/components/session-retry.tsx": "2cb1513d73275a269894f601e8c1d29bc790b14927134fe36177f2b371888721", "packages/session-ui/src/components/session-review.css": "ec8c5b359dd4b7ff80d7fad8906359678adade9ce62c9d74aedc000b646db341", @@ -1304,8 +1304,8 @@ "packages/ui/src/amicode/run-series.ts": "c94496068ae05f4600b9dd9fab4247b678dae7aaba31ef48e4389b0e7fefc52c", "packages/ui/src/amicode/run-view.tsx": "5098ef006cec0f354dde8589df1a730b5663c05300c1cf298569e62eb633f1be", "packages/ui/src/amicode/run-window.tsx": "411abb6064ca76a1866b91914be1788f78173844f5e963562733348f92d982b4", - "packages/ui/src/amicode/shell-row.test.ts": "30d1da7e9bd065c1c93d894b91a17ca73f8e8136c4cc6e1a8dedd95afdfdb13b", - "packages/ui/src/amicode/shell-row.ts": "1c95e2a8bc7ceca53f671429e5f35351188da26e7cb3aa0f9f4fa9fa6a38a0b2", + "packages/ui/src/amicode/shell-row.test.ts": "48ec6f0786edf81f163e350904bc2a93b1a55375b8115e688237ce8c2f1a7e64", + "packages/ui/src/amicode/shell-row.ts": "b39dc05d2b77ba6d44456248782b31f6219c891d56700203186a65ccb48319ad", "packages/ui/src/amicode/solver-switch.test.ts": "c9cc10d657d7adf0c388e56cb6058affed5a255bdeb9b8f2fea5c02f4daff1fe", "packages/ui/src/amicode/solver-switch.ts": "041c2ad3cfa63770cdc09ee5fb2cda0c7d09c55e7e53c83927822476716c6b92", "packages/ui/src/amicode/solver-toggle.test.ts": "2c5ede8eff8a60f1b5e6bb4423545438ebed6d5d60306d5bdc6f557144ede5e6", @@ -1363,7 +1363,7 @@ "packages/ui/src/components/amicode-run-card.tsx": "e9295cf59d46a71de0ee5b91c74eea3b4e42b70a9358f39a9a28c2578627f6b3", "packages/ui/src/components/amicode-run-gallery.tsx": "366919dc8b1fc48d4aeb454bc8f92e5e23621a4570c9084a2889c88952d6f692", "packages/ui/src/components/amicode-run-window.tsx": "5e5c623adc5d4e3fd35740e9b9cb5bae77e6f3f694850c5828f06a2616b4181f", - "packages/ui/src/components/amicode-shell-row.tsx": "fba08c4342bb775c099b1d71549708b7509ea29d83ac57401c99210a274f72c1", + "packages/ui/src/components/amicode-shell-row.tsx": "0ce6525dd2cb8c1b04c7779ae477417a7776ec83768b8b028d52817e734587a9", "packages/ui/src/components/amicode-solver-switch.tsx": "ea1e5d187fcbd505046af8c2bdcddeb7b30dd98a7d2ee9a4775e25be2c581270", "packages/ui/src/components/amicode-solver-toggle.tsx": "b09c870bab880bfdf2557fb870fb6af13a065f90353f59d609f37a6054db858d", "packages/ui/src/components/amicode-splash.tsx": "0f69ebdd0e4e6595f38b1492c31a28bd1320706c7f1e26411ec4d25d1ab2921f", @@ -1438,13 +1438,13 @@ "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": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", - "packages/app/src/components/session/pdf-canvas-view.tsx": "02032c64833583c9b0b62ed0ec7dc9770e7fa368b4659db45730118686ca6a27", - "packages/app/src/components/session/preview-file-view.tsx": "6e1949ed11bb3ea65023021aeec86875f2853bb84948d2147436c6f8be395da4", + "packages/app/src/components/session/pdf-canvas-view.tsx": "6ef8baff68fd9bdd2404eca46a7fe0bd3a99f999d354c81180deba571bdca8ab", + "packages/app/src/components/session/preview-file-view.tsx": "241ef052426209a2bdaf242efcd343a0efdc57826ccd4a059e331f420755128b", "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": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "4eb8127182f34aa9d141f5d9cb8eba330592ef0e2cf7c2cfe422e788f2fc67c5", + "packages/app/src/components/session/session-preview-tab.tsx": "b19e09eea767261fd412518d7f8eb711e6c15edbae1953081f96eff7dcd951b6", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", @@ -1495,12 +1495,12 @@ "packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81", "packages/app/src/pages/new-session/new-session-view.test.ts": "615110d1a956bb2321ca77c998d6f5da434756d166ee61cf71ca232cc9efb0c4", "packages/app/src/pages/new-session/new-session-view.tsx": "30b0b048f88ce741215e642b0760758638ad36696d2eb8770bc8e6b8b8f3fc29", - "packages/app/src/pages/session/helpers.test.ts": "a473d86117e3fddd25ba28a189d90fa35bcb80da7f2a152d3188e139f375e45d", - "packages/app/src/pages/session/helpers.ts": "8d0106a5ec3f01a666bd840e20b6bfb28d0e88b8c8c51fc1fdd7eaa33e9daafc", + "packages/app/src/pages/session/helpers.test.ts": "09fd272c286c7746db5ce635e1c453c8943e46111d030b4892af9145a89401db", + "packages/app/src/pages/session/helpers.ts": "0311f52b7cc34c2c38950512c01397e9672c89a9e61fdfa0e4696192da114f34", "packages/app/src/pages/session/session-panel-width.test.ts": "9482afba7fbce254cbbd21ef885e9e9620b981616681635376f466478f611155", "packages/app/src/pages/session/session-panel-width.ts": "02ee3a02ed78db2eac47c2528055cf78aef95f389641e929ddc56c17471dcbe2", - "packages/app/src/pages/session/session-side-panel-structure.test.ts": "c138fe905498c8326f459dccba61b146af6dfb12b78480303723b5a85046931a", - "packages/app/src/pages/session/session-side-panel.tsx": "4c93427154a87f78ac40b2d9b772cade515e5f4b31468fffdc4f5c228a84a908", + "packages/app/src/pages/session/session-side-panel-structure.test.ts": "b2d1d007d9200d0ac4a0889e45c2dbe57a324a10cb26ae9ab8f420d3c890402b", + "packages/app/src/pages/session/session-side-panel.tsx": "c3f3fb553be21dcbde98c389a09c66582bddbcaa94ce303d80f8e5158cd3dbda", "packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0", "packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111", "packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67", @@ -1562,7 +1562,7 @@ "packages/app/src/pages/session/composer/session-tour.tsx": "da5d807c7efe7aed1140d65d7eddd7982a8c17348d928f5d0a887d1b2a8d3a50", "packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "70a8267c725af320d78d266b85fd0ff005d067c4d09a21bef824126602e831e7", "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "83753b3ec7227570523c2e0b9e82401fae7a20f3a23980bb40deaeb13304327b", - "packages/app/src/pages/session/timeline/message-timeline.tsx": "2c0214ee9ee240340c4917287650da8d299c9bcc4eabd2fcc73a04c5bfa4749b", + "packages/app/src/pages/session/timeline/message-timeline.tsx": "718264c48becae24f8c55b0ef61684a26a9a12762edad9526c0b8caf9f6b097f", "packages/app/src/pages/session/timeline/model-pure.ts": "50be4ba12d747dd8df9932044ceee29c5f3b6fa6d6ac36791207f91a91067cd4", "packages/app/src/pages/session/timeline/model.test.ts": "459e0694409ffcf2530252a65491e1006f982b8f5c78a15573ed41646b2c85cf", "packages/app/src/pages/session/timeline/model.ts": "76cbb00ef94133db33b69851ae8eb5cc974b5f2414551cb48ba7b61f3cfa025e", diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx index 19128cf8..b22536e6 100644 --- a/packages/app-bundle/overlay/packages/app/src/app.tsx +++ b/packages/app-bundle/overlay/packages/app/src/app.tsx @@ -74,6 +74,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } import { createSessionLineage } from "@/pages/session/session-lineage" import { bugDockController } from "@/pages/session/composer/bug-dock-controller" import { postBugReportPoke } from "@/utils/amicode-bug-report" +import { adoptExplorerIconTheme } from "@/utils/vscode-explorer-icon-theme" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { LegacyHome } from "@/pages/home/legacy-home" @@ -426,7 +427,7 @@ function DraftProviders(props: ParentProps) { function AmicodeThemeBridge() { const theme = useTheme() const onMsg = (e: MessageEvent) => { - const d = e.data as { source?: string; kind?: string; colorScheme?: string } | undefined + const d = e.data as { source?: string; kind?: string; colorScheme?: string; theme?: unknown } | 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). @@ -446,11 +447,20 @@ function AmicodeThemeBridge() { adoptWorkspaceProjects((d as { projects?: unknown[] }).projects as Parameters[0]) return } + if (d.kind === "explorer-icon-theme") { + adoptExplorerIconTheme(d.theme) + 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)) + // Preview tabs need the current icon theme after every iframe boot; the host + // replies with opaque, allowlisted asset bytes rather than a file location. + if (window.parent !== window) { + window.parent.postMessage({ source: "amicode", kind: "explorer-icon-theme-request" }, "*") + } // ⌘⇧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 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 index 3eab0b92..9a279a62 100644 --- 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 @@ -145,13 +145,12 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): t: (key, params) => language.t(key as Parameters[0], params as never), }), ) - // The design placeholder takes the translate callback: the materialized - // helper (upstream v1.18.29 prompt-input/placeholder.ts) calls it in the - // non-shell branch, so the 2-arg form passes undefined there — minified - // "n is not a function" on every non-shell composer render. - // #929 (59b447e7) fixed this; the fork→overlay sync ff7b69c8 regressed it - // back to the 2-arg call. Restored per #964; guarded by - // overlay_known_fixes_964.test.ts (the pre-sync guard). + // The design placeholder takes the translate callback: the helper calls it + // in the non-shell branch, so the 2-arg form passes undefined there — + // minified "n is not a function" on every non-shell composer render. + // #929 (59b447e7) fixed this amicode-side; the fork→overlay sync ff7b69c8 + // regressed it back to the 2-arg call. Mirrored to the sync source per + // harmoniqs/amicode#964 so the next sync brings the fix, not the regression. const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder(), (key, params) => language.t(key as Parameters[0], params as never), diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx index 32992d8a..540af98d 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx @@ -2,7 +2,8 @@ * pdf-canvas-view — Renders PDF pages to elements via PDF.js. * * Uses pdfjs-dist (Mozilla's PDF renderer) to decode PDF binary data and - * paint each page onto a canvas. No browser plugin, no iframe, no Chromium + * paint each page onto a canvas, with a DOM text layer over the same viewport + * for native selection and copy. No browser plugin, no iframe, no Chromium * PDF viewer — works in any context including VS Code sandboxed webviews. * * Worker runs on the main thread via globalThis.pdfjsWorker injection @@ -45,6 +46,53 @@ interface PdfCanvasViewProps { // Wrapper padding: p-4 = 16px each side const WRAPPER_PADDING = 32 +const ZOOM_RENDER_DEBOUNCE_MS = 100 + +// PDF.js generates positioned spans but intentionally leaves their layout CSS +// to its host viewer. Keep the layer transparent so canvas remains authoritative +// for appearance while the browser can still select and copy its real text. +const PDF_TEXT_LAYER_STYLE = ` +[data-pdf-text-layer] { + position: absolute; + inset: 0; + overflow: hidden; + line-height: 1; + text-align: initial; + text-size-adjust: none; + transform-origin: 0 0; + z-index: 1; + --user-unit: 1; + --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); + --scale-round-x: 1px; + --scale-round-y: 1px; + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +[data-pdf-text-layer] :is(span, br) { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + transform-origin: 0 0; + user-select: text; +} + +[data-pdf-text-layer] > :not(.markedContent), +[data-pdf-text-layer] .markedContent span:not(.markedContent) { + z-index: 1; + --font-height: 0; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + --scale-x: 1; + --rotate: 0deg; + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv)); +} + +[data-pdf-text-layer] ::selection { + background: Highlight; + color: transparent; +} +` // --------------------------------------------------------------------------- // Single page renderer @@ -56,71 +104,125 @@ function PdfPage(props: { zoom: number /** Available content width in CSS pixels (container minus padding) */ containerWidth: number + onTextAvailability: (available: boolean) => void }) { + const [page, setPage] = createSignal(null) let canvasRef: HTMLCanvasElement | undefined + let textLayerRef: HTMLDivElement | undefined let activeRender: { cancel(): void } | null = null + let activeTextLayer: pdfjsLib.TextLayer | null = null + let renderTimer: ReturnType | undefined - // Re-render whenever zoom, container width, or page changes + // Load the PDF page and text stream once. Zoom and resize retain the text + // layer, avoiding a fresh stream read and DOM reconstruction each time. createEffect(() => { const doc = props.doc - const zoom = props.zoom const pageNum = props.pageNum - const containerWidth = props.containerWidth - if (!canvasRef || !doc || containerWidth <= 0) return + if (!doc) return - // Cancel any in-flight render from a previous reactive cycle - if (activeRender) { - activeRender.cancel() - activeRender = null - } + setPage(null) + activeTextLayer?.cancel() + activeTextLayer = null + textLayerRef?.replaceChildren() let cancelled = false doc.getPage(pageNum).then((page) => { - if (cancelled || !canvasRef) return - - const dpr = window.devicePixelRatio || 1 + if (cancelled) return + setPage(page) + }) - // Get intrinsic page size (PDF points at scale=1) - const intrinsic = page.getViewport({ scale: 1 }) + onCleanup(() => { + cancelled = true + activeTextLayer?.cancel() + activeTextLayer = null + textLayerRef?.replaceChildren() + }) + }) - // Base scale: fit the page width to the container at 100% zoom. - // User zoom multiplies on top: 200% = twice the container width. - const baseScale = containerWidth / intrinsic.width - const scale = baseScale * (zoom / 100) * dpr - const viewport = page.getViewport({ scale }) + // Keep the last painted canvas visible while rapid zoom input settles. The + // new high-resolution raster is staged offscreen, then copied in atomically. + createEffect(() => { + const currentPage = page() + const zoom = props.zoom + const containerWidth = props.containerWidth + if (!canvasRef || !textLayerRef || !currentPage || containerWidth <= 0) return + + const dpr = window.devicePixelRatio || 1 + const intrinsic = currentPage.getViewport({ scale: 1 }) + const scale = (containerWidth / intrinsic.width) * (zoom / 100) + const viewport = currentPage.getViewport({ scale }) + const canvasViewport = currentPage.getViewport({ scale: scale * dpr }) + + textLayerRef.style.setProperty("--scale-factor", `${scale}`) + // Uniform zoom is expressed through the layer's CSS scale variable, so the + // existing DOM spans remain valid without a fresh text stream read. + if (!activeTextLayer) { + const textLayer = new pdfjsLib.TextLayer({ + textContentSource: currentPage.streamTextContent(), + container: textLayerRef, + viewport, + }) + activeTextLayer = textLayer + void textLayer.render().then(() => { + if (activeTextLayer !== textLayer) return + props.onTextAvailability(textLayer.textContentItemsStr.some((text) => text.trim().length > 0)) + }).catch(() => { + if (activeTextLayer !== textLayer) return + props.onTextAvailability(false) + }) + } - canvasRef.width = viewport.width - canvasRef.height = viewport.height - // CSS size = physical ÷ dpr so it's crisp on HiDPI - canvasRef.style.width = `${viewport.width / dpr}px` - canvasRef.style.height = `${viewport.height / dpr}px` + // Scale the last completed bitmap immediately; it preserves the page's + // geometry during the gesture while the crisp replacement is rendered. + canvasRef.style.width = `${canvasViewport.width / dpr}px` + canvasRef.style.height = `${canvasViewport.height / dpr}px` - const ctx = canvasRef.getContext("2d") + const render = () => { + const staging = document.createElement("canvas") + staging.width = canvasViewport.width + staging.height = canvasViewport.height + const ctx = staging.getContext("2d") if (!ctx) return - const task = page.render({ canvas: null, canvasContext: ctx, viewport }) + const task = currentPage.render({ canvas: null, canvasContext: ctx, viewport: canvasViewport }) activeRender = task task.promise - .then(() => { activeRender = null }) - .catch(() => { activeRender = null }) - }) + .then(() => { + if (activeRender !== task || !canvasRef) return + canvasRef.width = staging.width + canvasRef.height = staging.height + canvasRef.getContext("2d")?.drawImage(staging, 0, 0) + }) + .catch(() => {}) + .finally(() => { + if (activeRender === task) activeRender = null + }) + } + + if (canvasRef.width === 0 || canvasRef.height === 0) { + render() + } else { + renderTimer = setTimeout(render, ZOOM_RENDER_DEBOUNCE_MS) + } onCleanup(() => { - cancelled = true - if (activeRender) { - activeRender.cancel() - activeRender = null - } + if (renderTimer) clearTimeout(renderTimer) + renderTimer = undefined + activeRender?.cancel() + activeRender = null }) }) return ( - +
+ +
+
) } @@ -133,6 +235,7 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { const [pdfDoc, setPdfDoc] = createSignal(null) const [error, setError] = createSignal(false) const [containerWidth, setContainerWidth] = createSignal(0) + const [textAvailability, setTextAvailability] = createSignal>({}) let wrapperRef: HTMLDivElement | undefined @@ -170,6 +273,7 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { setError(false) setPageCount(0) setPdfDoc(null) + setTextAvailability({}) try { // Decode base64 → Uint8Array @@ -205,8 +309,23 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { if (doc) doc.cleanup() }) + const textAvailabilityMessage = () => { + const availability = textAvailability() + if (pageCount() === 0 || Object.keys(availability).length !== pageCount()) return null + const pagesWithText = Object.values(availability).filter(Boolean).length + if (pagesWithText === 0) return "This PDF has no selectable text." + if (pagesWithText < pageCount()) return "Text selection is unavailable on some pages." + return null + } + return (
+ + {textAvailabilityMessage() && ( +

+ {textAvailabilityMessage()} +

+ )} {/* Rendered pages */} i + 1)}> {(pageNum) => ( @@ -215,6 +334,9 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { pageNum={pageNum} zoom={props.zoom} containerWidth={containerWidth()} + onTextAvailability={(available) => { + setTextAvailability((current) => ({ ...current, [pageNum]: available })) + }} /> )} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx index d193f28b..a40e6125 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx @@ -27,7 +27,6 @@ import { preprocessMarkdown } from "@opencode-ai/session-ui/v2/markdown-utils" import { RENDERABLE_EXTENSIONS } from "@opencode-ai/session-ui/v2/markdown-utils" import { useSDK } from "@/context/sdk" import { useServerSDK } from "@/context/server-sdk" -import type { PreviewFileState } from "@opencode-ai/session-ui/v2/preview-nav-state" import { PreviewEditor } from "@opencode-ai/session-ui/v2/preview-editor" import { PdfCanvasView } from "./pdf-canvas-view" @@ -69,19 +68,20 @@ const IMAGE_WRAPPER_PADDING = 32 export function PreviewFileView(props: { filePath: string - fileState: PreviewFileState - onModeChange: (mode: "preview" | "edit") => void - onUnsavedContent: (content: string | null) => void - onSave: (path: string, content: string) => void + onDirtyChange: (dirty: boolean) => void + onSaveComplete?: () => void + saveRequest?: () => number onSaveStatusChange?: (status: "idle" | "saving" | "saved") => void zoom: () => number - zoomIn: () => void + zoomIn: (maximum: number) => void zoomOut: () => void - onZoomChange?: (zoom: number) => void + onZoomChange?: (zoom: number, maximum: number) => void }) { const sdk = useSDK() const serverSDK = useServerSDK() const [fileContent, setFileContent] = createSignal("") + const [mode, setMode] = createSignal<"preview" | "edit">("preview") + const [unsavedContent, setUnsavedContent] = createSignal(null) const [loading, setLoading] = createSignal(true) const [fileType, setFileType] = createSignal(null) @@ -171,12 +171,14 @@ export function PreviewFileView(props: { props.onSaveStatusChange?.(saveStatus()) }) - const saveFile = async (filePath: string, content: string) => { + const saveFile = async (filePath: string, content: string, closeAfterSave = false) => { setSaveStatus("saving") try { await serverSDK().client.file.write({ path: filePath, content }) setSaveStatus("saved") - props.onUnsavedContent(null) + setUnsavedContent(null) + props.onDirtyChange(false) + if (closeAfterSave) props.onSaveComplete?.() if (savedTimer) clearTimeout(savedTimer) savedTimer = setTimeout(() => setSaveStatus("idle"), 2000) } catch { @@ -185,16 +187,27 @@ export function PreviewFileView(props: { } const handleEdit = (content: string) => { - props.onUnsavedContent(content) + setUnsavedContent(content) + props.onDirtyChange(true) setFileContent(content) } const handleImmediateSave = () => { - if (props.fileState.unsavedContent !== null) { - saveFile(props.filePath, props.fileState.unsavedContent) - } + const content = unsavedContent() + if (content !== null) void saveFile(props.filePath, content) } + createEffect( + on( + () => props.saveRequest?.() ?? 0, + (request) => { + if (request === 0) return + const content = unsavedContent() + if (content !== null) void saveFile(props.filePath, content, true) + }, + ), + ) + onCleanup(() => { if (savedTimer) clearTimeout(savedTimer) }) @@ -206,7 +219,7 @@ export function PreviewFileView(props: { // Zoom is disabled in edit mode — pill disappears entirely const isEditing = () => { const cat = category() - if (cat === "markdown") return props.fileState.mode === "edit" + if (cat === "markdown") return mode() === "edit" if (cat === "image" || cat === "pdf") return false return true // text/code files are always in edit mode } @@ -253,21 +266,38 @@ export function PreviewFileView(props: { const cat = category() return cat === "image" || cat === "pdf" ? 100 : 50 } + const zoomCeiling = () => { + const cat = category() + return cat === "image" || cat === "pdf" ? 1000 : 500 + } const handleZoomOut = () => { if (props.zoom() <= zoomFloor()) return const before = props.zoom() + adjustScrollForZoom(before, Math.max(before - 10, zoomFloor())) props.zoomOut() - adjustScrollForZoom(before, props.zoom()) } - // ─── Scroll-centered zoom ──────────────────────────────────────────── - // After a zoom change, adjust scroll so the viewport center stays fixed. - // Uses rAF to let the DOM update first: image CSS reflows synchronously, - // PDF canvas dimensions settle as a microtask (getPage().then()), and - // rAF fires after both — so scrollWidth/scrollHeight are correct. + // ─── Focus-preserving zoom ─────────────────────────────────────────── + // After a zoom change, adjust scroll so the focus point stays fixed. + // Toolbar and typed zoom use the viewport center; wheel zoom uses its + // pointer location. A single rAF coalesces a gesture instead of letting + // stale scroll positions from earlier wheel events overwrite the latest. let scrollRef: HTMLDivElement | undefined + let zoomFrame: number | undefined + let pendingZoom: + | { + oldZoom: number + newZoom: number + focusX: number + focusY: number + contentX: number + contentY: number + element: HTMLDivElement + host: HTMLElement | null + } + | undefined // ─── Track scroll container width for image sizing ─────────────────── // Observe scrollRef (the scroll container) to get its content width. @@ -293,17 +323,41 @@ export function PreviewFileView(props: { // At 200% it's twice that, etc. Deterministic sizing — the inline-flex // wrapper sizes correctly around it, so justify-center never pushes // content into unreachable negative scroll territory. - const imageWidth = () => Math.max(0, (containerWidth() - IMAGE_WRAPPER_PADDING) * props.zoom() / 100) + const imageWidth = () => Math.max(0, ((containerWidth() - IMAGE_WRAPPER_PADDING) * props.zoom()) / 100) - const adjustScrollForZoom = (oldZoom: number, newZoom: number) => { + const adjustScrollForZoom = (oldZoom: number, newZoom: number, focus?: { x: number; y: number }) => { const el = scrollRef if (!el || oldZoom === newZoom || oldZoom === 0) return - const ratio = newZoom / oldZoom - const centerX = el.scrollLeft + el.clientWidth / 2 - const centerY = el.scrollTop + el.clientHeight / 2 - requestAnimationFrame(() => { - el.scrollLeft = centerX * ratio - el.clientWidth / 2 - el.scrollTop = centerY * ratio - el.clientHeight / 2 + const focusX = Math.min(Math.max(focus?.x ?? el.clientWidth / 2, 0), el.clientWidth) + const focusY = Math.min(Math.max(focus?.y ?? el.clientHeight / 2, 0), el.clientHeight) + + if (pendingZoom) { + pendingZoom.newZoom = newZoom + pendingZoom.focusX = focusX + pendingZoom.focusY = focusY + } else { + pendingZoom = { + oldZoom, + newZoom, + focusX, + focusY, + contentX: el.scrollLeft + focusX, + contentY: el.scrollTop + focusY, + element: el, + host: el.closest("[data-preview-host]"), + } + } + + if (zoomFrame) return + zoomFrame = requestAnimationFrame(() => { + zoomFrame = undefined + const pending = pendingZoom + pendingZoom = undefined + if (!pending) return + const ratio = pending.newZoom / pending.oldZoom + const element = pending.host?.querySelector("[data-preview-scroll]") ?? pending.element + element.scrollLeft = pending.contentX * ratio - pending.focusX + element.scrollTop = pending.contentY * ratio - pending.focusY }) } @@ -314,19 +368,23 @@ export function PreviewFileView(props: { // — Solid's onWheel is passive by default and can't preventDefault. const handleWheelZoom = (e: WheelEvent) => { - if (!e.ctrlKey && !e.shiftKey) return // normal scroll — pass through + if (!e.ctrlKey && !e.shiftKey) return // normal scroll — pass through e.preventDefault() if (!props.onZoomChange) return - const delta = e.deltaY || e.deltaX // shift+scroll may swap axes + const delta = e.deltaY || e.deltaX // shift+scroll may swap axes if (delta === 0) return const oldZoom = props.zoom() const factor = Math.exp(-delta * 0.003) - const next = Math.round( - Math.min(Math.max(oldZoom * factor, zoomFloor()), 500), - ) + const next = Math.round(Math.min(Math.max(oldZoom * factor, zoomFloor()), zoomCeiling())) if (next === oldZoom) return - props.onZoomChange(next) - adjustScrollForZoom(oldZoom, next) + const scroll = scrollRef + if (!scroll) return + const bounds = scroll.getBoundingClientRect() + adjustScrollForZoom(oldZoom, next, { + x: e.clientX - bounds.left, + y: e.clientY - bounds.top, + }) + props.onZoomChange(next, zoomCeiling()) setShowControls(true) startIdleTimer() } @@ -347,24 +405,59 @@ export function PreviewFileView(props: { > {/* Floating controls — top-right overlay */}
+ +
+ { + if (value === "preview" || value === "edit") { + setMode(value) + } + }} + class="!w-auto" + aria-label="View mode" + > + + + + + + + + + + + +
+
{/* Zoom controls: [editable %] [reset] [+ over -] */} -
+
{/* Editable zoom percentage input */} { const val = parseInt(e.currentTarget.value) if (!isNaN(val) && props.onZoomChange) { - const clamped = Math.min(Math.max(val, zoomFloor()), 500) + const clamped = Math.min(Math.max(val, zoomFloor()), zoomCeiling()) const before = props.zoom() - props.onZoomChange(clamped) adjustScrollForZoom(before, clamped) + props.onZoomChange(clamped, zoomCeiling()) } e.currentTarget.value = `${props.zoom()}%` }} @@ -398,12 +491,21 @@ export function PreviewFileView(props: { class="flex items-center justify-center w-6 h-full border-l border-border-base text-text-weak hover:text-text-base hover:bg-background-stronger transition-colors" onClick={() => { const before = props.zoom() - props.onZoomChange?.(100) adjustScrollForZoom(before, 100) + props.onZoomChange?.(100, zoomCeiling()) }} aria-label="Reset zoom" > - + @@ -414,8 +516,8 @@ export function PreviewFileView(props: { class="flex items-center justify-center w-5 h-3.5 text-text-weak hover:text-text-base hover:bg-background-stronger transition-colors" onClick={() => { const before = props.zoom() - props.zoomIn() - adjustScrollForZoom(before, props.zoom()) + adjustScrollForZoom(before, Math.min(before + 10, zoomCeiling())) + props.zoomIn(zoomCeiling()) }} aria-label="Zoom in" > @@ -431,35 +533,10 @@ export function PreviewFileView(props: {
- -
- { - if (value === "preview" || value === "edit") { - props.onModeChange(value) - } - }} - class="!w-auto" - aria-label="View mode" - > - - - - - - - - - - - -
-
{/* Content */} -
+
Loading...
}> @@ -503,7 +580,7 @@ export function PreviewFileView(props: { {/* Text-based rendering by category */} -}) { - // ─── Per-file State ────────────────────────────────────────────────────── +type PreviewRailReorder = { + direction: "source" | "source-remote" | "left" | "right" + width: number + offset?: number +} - const [fileStates, setFileStates] = createStore({}) +type PreviewLeafEdges = { + top: boolean + right: boolean + bottom: boolean + left: boolean +} - const [zoom, setZoom] = createSignal(100) +export function SessionPreviewTab(props: { previewFile: Accessor }) { + const platform = usePlatform() + const [dirtyPaths, setDirtyPaths] = createStore>({}) + const [saveRequests, setSaveRequests] = createStore>({}) + const [workspace, setWorkspace] = createSignal(createPreviewWorkspace()) + const [capacityMessage, setCapacityMessage] = createSignal(null) + const [closingPath, setClosingPath] = createSignal(null) + const [previewDrag, setPreviewDrag] = createSignal(null) + const [dragProxy, setDragProxy] = createSignal<{ path: string; x: number; y: number; leaving: boolean } | null>(null) + const [resizingSplitID, setResizingSplitID] = createSignal(null) + const [tabContextMenu, setTabContextMenu] = createSignal<{ path: string; x: number; y: number } | null>(null) - const zoomIn = () => setZoom((z) => Math.min(z + 10, 500)) - const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) - const onZoomChange = (value: number) => setZoom(Math.round(Math.min(Math.max(value, 50), 500))) + const leafElements = new Map() + const [hostMounts, setHostMounts] = createStore>({}) + let stopPreviewDrag: (() => void) | undefined + let dragProxyTimer: ReturnType | undefined - const getFileState = (path: string): PreviewFileState => { - return fileStates[path] ?? { mode: "preview", scrollPosition: 0, unsavedContent: null } - } + const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs) - const setFileState = (path: string, update: Partial) => { - const defaults: PreviewFileState = { mode: "preview", scrollPosition: 0, unsavedContent: null } - setFileStates(path, (prev) => ({ - ...defaults, - ...prev, - ...update, - })) + const openPath = (path: string) => { + const current = workspace() + if (!previewLeafContaining(current.tree, path) && previewTabCount(current.tree) === MAX_PREVIEW_TABS) { + setCapacityMessage("Close an existing Preview tab before opening another file.") + return + } + setWorkspace(openPreviewPath(current, path)) + setCapacityMessage(null) } - // ─── Header Display ───────────────────────────────────────────────────── + createEffect( + on( + () => props.previewFile(), + (path) => { + if (path) openPath(path) + }, + ), + ) - const headerTitle = createMemo(() => { - const file = props.previewFile() - if (file) { - const parts = file.split("/") - return parts[parts.length - 1] + onMount(() => { + const handlePreviewFile = (event: MessageEvent) => { + const data = event.data as { source?: string; kind?: string; path?: string } | undefined + if (data?.source === "amicode" && data.kind === "preview-file" && data.path) openPath(data.path) + } + const focusLeafFromPointer = (event: PointerEvent) => { + if (!(event.target instanceof Node)) return + for (const [leafID, element] of leafElements) { + if (!element.contains(event.target)) continue + setWorkspace((current) => (current.focusedLeafID === leafID ? current : { ...current, focusedLeafID: leafID })) + return + } } - return "Preview" + window.addEventListener("message", handlePreviewFile) + document.addEventListener("pointerdown", focusLeafFromPointer, true) + onCleanup(() => { + window.removeEventListener("message", handlePreviewFile) + document.removeEventListener("pointerdown", focusLeafFromPointer, true) + }) + }) + onCleanup(() => { + stopPreviewDrag?.() + if (dragProxyTimer) clearTimeout(dragProxyTimer) }) - // ─── Render ───────────────────────────────────────────────────────────── + const zoomForPath = (path: string) => previewLeafContaining(workspace().tree, path)?.zoom ?? 100 + const setZoomForPath = (path: string, value: number, maximum = 500) => { + const leaf = previewLeafContaining(workspace().tree, path) + if (leaf) setWorkspace((current) => setPreviewLeafZoom(current, leaf.id, value, maximum)) + } + + const removePath = (path: string) => { + setWorkspace((current) => removePreviewPath(current, path)) + setDirtyPaths(path, false) + setHostMounts(path, undefined) + } + + const closePath = (path: string) => { + if (dirtyPaths[path]) { + setClosingPath(path) + return + } + removePath(path) + } + + const copyPreviewPath = async (value: string) => { + if (platform.writeClipboardText && (await platform.writeClipboardText(value))) return + try { + await navigator.clipboard?.writeText(value) + } catch {} + } + + const openTabContextMenu = (event: MouseEvent, path: string) => { + event.preventDefault() + setTabContextMenu({ path, x: event.clientX, y: event.clientY }) + } + + const dropTargetAt = ( + clientX: number, + clientY: number, + path: string, + sourceLeafID: string, + sourceStartX: number, + sourceGrabOffset: number, + ): PreviewDrop | undefined => { + for (const leaf of previewLeaves(workspace().tree)) { + const element = leafElements.get(leaf.id) + const rect = element?.getBoundingClientRect() + if ( + !element || + !rect || + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) + continue + + const tabsList = element.querySelector('[data-slot="tabs-list"]') + const tabsRect = tabsList?.getBoundingClientRect() + if ( + tabsList && + tabsRect && + clientX >= tabsRect.left && + clientX <= tabsRect.right && + clientY >= tabsRect.top && + clientY <= tabsRect.bottom + ) { + const tabs = Array.from(element.querySelectorAll("[data-preview-tab]")) + const sourceLeaf = previewLeaves(workspace().tree).find((candidate) => candidate.id === sourceLeafID) + const insertionTabs = sourceLeaf?.id === leaf.id ? tabs.filter((tab) => tab.dataset.previewTab !== path) : tabs + const targetIndex = insertionTabs.findIndex((tab) => { + const midpoint = tabsRect.left + tab.offsetLeft - tabsList.offsetLeft + tab.offsetWidth / 2 + return clientX < midpoint + }) + const insertionIndex = targetIndex === -1 ? insertionTabs.length : targetIndex + const sourceOffset = sourceLeaf?.id === leaf.id ? clientX - sourceGrabOffset - sourceStartX : undefined + return { + leafID: leaf.id, + position: "center", + targetIndex: insertionIndex, + sourceOffset, + kind: "rail", + } + } + + const content = element.querySelector(".preview-pane-content") + const contentRect = content?.getBoundingClientRect() + if (!contentRect) return undefined + + const position: PreviewDropPosition = + clientX - contentRect.left < DROP_EDGE_PX + ? "left" + : contentRect.right - clientX < DROP_EDGE_PX + ? "right" + : clientY - contentRect.top < DROP_EDGE_PX + ? "top" + : contentRect.bottom - clientY < DROP_EDGE_PX + ? "bottom" + : "center" + + if (position !== "center" && sourceLeafID === leaf.id && leaf.tabs.length === 1) return undefined + + if (position !== "center") return { leafID: leaf.id, position, kind: "pane" } + + const tabs = Array.from(element.querySelectorAll("[data-preview-tab]")) + const targetIndex = tabs.findIndex((tab) => { + const tabRect = tab.getBoundingClientRect() + return clientX < tabRect.left + tabRect.width / 2 + }) + return { leafID: leaf.id, position, targetIndex: targetIndex === -1 ? tabs.length : targetIndex, kind: "pane" } + } + return undefined + } + + const startPreviewDrag = (event: PointerEvent, path: string, sourceLeafID: string) => { + event.stopPropagation() + if ( + event.button !== 0 || + (event.target instanceof Element && event.target.closest('[data-slot="tabs-trigger-close-button"]')) + ) + return + + const activeElement = document.activeElement + const focusTarget = + activeElement instanceof HTMLElement && + activeElement.closest("[data-preview-host]")?.getAttribute("data-preview-host") === path + ? activeElement + : undefined + const focusScroller = focusTarget?.closest(".cm-scroller") + const focusScrollTop = focusScroller?.scrollTop + const origin = { x: event.clientX, y: event.clientY } + let active = false + const source = event.currentTarget as HTMLElement + const sourceRect = + source.closest("[data-preview-tab]")?.getBoundingClientRect() ?? source.getBoundingClientRect() + const sourceWidth = sourceRect.width + const sourceGrabOffset = event.clientX - sourceRect.left + source.setPointerCapture?.(event.pointerId) + const setDocumentDrag = (dragging: boolean) => + document.documentElement.toggleAttribute("data-preview-tab-dragging", dragging) + + const clearDragProxy = (fade: boolean) => { + const proxy = dragProxy() + if (!proxy) return + if (!fade) { + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = undefined + setDragProxy(null) + return + } + setDragProxy({ ...proxy, leaving: true }) + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = setTimeout(() => { + setDragProxy(null) + dragProxyTimer = undefined + }, 160) + } + const stop = (fadeProxy = false) => { + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + window.removeEventListener("pointercancel", onCancel) + if (source.hasPointerCapture?.(event.pointerId)) source.releasePointerCapture(event.pointerId) + stopPreviewDrag = undefined + setPreviewDrag(null) + setDocumentDrag(false) + clearDragProxy(fadeProxy) + } + const onMove = (moveEvent: PointerEvent) => { + if (!active) { + if (Math.hypot(moveEvent.clientX - origin.x, moveEvent.clientY - origin.y) < 4) return + active = true + setDocumentDrag(true) + } + moveEvent.preventDefault() + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = undefined + const next = { + path, + sourceLeafID, + width: sourceWidth, + x: moveEvent.clientX, + y: moveEvent.clientY, + drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID, sourceRect.left, sourceGrabOffset), + } + setPreviewDrag(next) + setDragProxy(next.drop?.kind === "rail" ? null : { path, x: next.x, y: next.y, leaving: false }) + } + const onUp = (upEvent: PointerEvent) => { + const drop = active + ? dropTargetAt(upEvent.clientX, upEvent.clientY, path, sourceLeafID, sourceRect.left, sourceGrabOffset) + : undefined + stop(!drop) + if (!drop) return + setWorkspace((current) => + movePreviewTab(current, { + path, + targetLeafID: drop.leafID, + position: drop.position, + targetIndex: drop.targetIndex, + }), + ) + requestAnimationFrame(() => { + if (focusTarget?.isConnected) focusTarget.focus({ preventScroll: true }) + requestAnimationFrame(() => { + if (focusScroller?.isConnected && focusScrollTop !== undefined) focusScroller.scrollTop = focusScrollTop + }) + }) + } + const onCancel = () => stop(true) + + stopPreviewDrag?.() + stopPreviewDrag = stop + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + window.addEventListener("pointercancel", onCancel) + } + + const isSelected = (path: string) => previewLeafContaining(workspace().tree, path)?.selectedPath === path + + const railReorder = (leaf: PreviewLeaf, path: string): PreviewRailReorder | undefined => { + const drag = previewDrag() + const drop = drag?.drop + if (!drag || !drop || drop.kind !== "rail") return undefined - // Dirty state: true when the current file has unsaved edits - const isUnsaved = createMemo(() => { - const file = props.previewFile() - if (!file) return false - return fileStates[file]?.unsavedContent != null + const source = previewLeaves(workspace().tree).find((candidate) => candidate.id === drag.sourceLeafID) + if (!source) return undefined + const sourceIndex = source.tabs.indexOf(drag.path) + if (sourceIndex === -1) return undefined + + if (leaf.id === source.id && path === drag.path) { + const direction: PreviewRailReorder["direction"] = drop.leafID === source.id ? "source" : "source-remote" + return { + direction, + width: drag.width, + offset: drop.sourceOffset, + } + } + + const index = leaf.tabs.indexOf(path) + if (index === -1) return undefined + + if (source.id === drop.leafID && leaf.id === source.id) { + const targetIndex = Math.max(0, Math.min(drop.targetIndex ?? source.tabs.length - 1, source.tabs.length - 1)) + if (sourceIndex > targetIndex && index >= targetIndex && index < sourceIndex) + return { direction: "right" as const, width: drag.width } + if (sourceIndex < targetIndex && index > sourceIndex && index <= targetIndex) + return { direction: "left" as const, width: drag.width } + return undefined + } + + return undefined + } + + const startDividerResize = (event: PointerEvent, split: PreviewSplit) => { + event.preventDefault() + event.stopPropagation() + const divider = event.currentTarget as HTMLElement + const container = divider.parentElement + if (!container) return + const rect = container.getBoundingClientRect() + const axis = split.direction + const extent = axis === "horizontal" ? rect.width : rect.height + const start = axis === "horizontal" ? rect.left : rect.top + const firstMinimum = previewMinimumExtent(split.first, axis) + const secondMinimum = previewMinimumExtent(split.second, axis) + if (extent <= 0 || firstMinimum + secondMinimum > extent) return + + setResizingSplitID(split.id) + divider.setPointerCapture?.(event.pointerId) + const stop = () => { + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + window.removeEventListener("pointercancel", stop) + if (divider.hasPointerCapture?.(event.pointerId)) divider.releasePointerCapture(event.pointerId) + setResizingSplitID((current) => (current === split.id ? null : current)) + } + const onMove = (moveEvent: PointerEvent) => { + moveEvent.preventDefault() + const coordinate = axis === "horizontal" ? moveEvent.clientX : moveEvent.clientY + const ratio = Math.min(Math.max((coordinate - start) / extent, firstMinimum / extent), 1 - secondMinimum / extent) + setWorkspace((current) => resizePreviewSplit(current, split.id, ratio)) + } + const onUp = () => stop() + + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + window.addEventListener("pointercancel", stop) + } + + const paneBranchStyle = (pane: PreviewPane, ratio: number) => ({ + flex: `${ratio} 1 0`, + "--preview-branch-min-width": `${previewMinimumExtent(pane, "horizontal")}px`, + "--preview-branch-min-height": `${previewMinimumExtent(pane, "vertical")}px`, }) - return ( -
- {/* Header: filename + unsaved dot */} -
-
- - {headerTitle()} - - -
- + const renderPane = ( + pane: PreviewPane, + edges: PreviewLeafEdges = { top: true, right: true, bottom: true, left: true }, + ): JSX.Element => { + if (pane.kind === "leaf") return renderLeaf(pane, edges) + const firstEdges = pane.direction === "horizontal" ? { ...edges, right: false } : { ...edges, bottom: false } + const secondEdges = pane.direction === "horizontal" ? { ...edges, left: false } : { ...edges, top: false } + return ( +
+
+ {renderPane(pane.first, firstEdges)} +
+ + ) + } - {/* Main content */} -
- - -

Select a file from the sidebar

-
- } + const renderLeaf = (leaf: PreviewLeaf, edges: PreviewLeafEdges): JSX.Element => { + const activeDrop = () => { + const drop = previewDrag()?.drop + return drop?.kind === "pane" ? drop : undefined + } + const railGhost = () => { + const drag = previewDrag() + const drop = drag?.drop + if (!drag || !drop || drop.kind !== "rail" || drop.leafID !== leaf.id || drag.sourceLeafID === leaf.id) + return undefined + return { path: drag.path, index: Math.max(0, Math.min(drop.targetIndex ?? leaf.tabs.length, leaf.tabs.length)) } + } + const RailGhost = () => { + const ghost = railGhost() + if (!ghost) return null + return ( + + ) + } + return ( +
leafElements.set(leaf.id, element)} + data-preview-leaf={leaf.id} + data-preview-edge-top={edges.top || undefined} + data-preview-edge-right={edges.right || undefined} + data-preview-edge-bottom={edges.bottom || undefined} + data-preview-edge-left={edges.left || undefined} + data-focused={workspace().focusedLeafID === leaf.id || undefined} + class="preview-pane-leaf" + > + setWorkspace((current) => selectPreviewPath(current, leaf.id, path))} + class="shrink-0" + classList={{ "preview-tab-strip": true }} > - {(filePath) => ( - setFileState(filePath(), { mode })} - onUnsavedContent={(content) => setFileState(filePath(), { unsavedContent: content })} - onSave={() => {/* handled by PreviewFileView internally */}} - zoom={zoom} - zoomIn={zoomIn} - zoomOut={zoomOut} - onZoomChange={onZoomChange} - /> + + + {(path, index) => { + const reorder = () => railReorder(leaf, path) + return ( + <> + + + +
+ startPreviewDrag(event, path, leaf.id)} + onContextMenu={(event: MouseEvent) => openTabContextMenu(event, path)} + closeButton={ + + } + hideCloseButton + onMiddleClick={() => closePath(path)} + > + + +
+ + ) + }} +
+ + + +
+
+ +
{ + for (const path of leaf.tabs) setHostMounts(path, element) + }} + class="preview-pane-content" + /> + + +
+ +
+ ) + } + + return ( + <> +
+ + {(proxy) => ( + + + )} + + + + + + {(path) => ( + + )} + + +
+ 0} + fallback={ +
+ +

Select a file from the sidebar

+
+ } + > +
+ {(pane) => renderPane(pane)} + + {(path) => ( + + {(mount) => ( + container.classList.add("preview-pane-renderer-mount")} + > + + + )} + + )} + +
+
+
-
+ !open && setTabContextMenu(null)}> + + + void copyPreviewPath((tabContextMenu()?.path ?? "").split("/").at(-1) ?? "")}> + Copy filename + + void copyPreviewPath(tabContextMenu()?.path ?? "")}>Copy filepath + + + + ) } diff --git a/packages/app-bundle/overlay/packages/app/src/context/layout.tsx b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx index 35609af0..09ab2622 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/layout.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx @@ -21,6 +21,12 @@ import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./la import { requireServerKey } from "@/utils/session-route" import { type DraftTab, useTabs } from "./tabs" import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" +import { + DEFAULT_SIDE_PANEL_TAB_ORDER, + normalizeSidePanelTabOrder, + reorderSidePanelTabs, + type SidePanelTabID, +} from "./layout-side-panel-tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -228,6 +234,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } })() + const sidePanelTabs = value.sidePanelTabs + const migratedSidePanelTabs = (() => { + const order = isRecord(sidePanelTabs) ? sidePanelTabs.order : undefined + const normalized = normalizeSidePanelTabOrder(order) + if (Array.isArray(order) && same(order, normalized)) return sidePanelTabs + return { ...(isRecord(sidePanelTabs) ? sidePanelTabs : {}), order: normalized } + })() + const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs) const sessionView = migrateLegacySessionStateKeys(value.sessionView) const migratedSessionTabs = (() => { @@ -258,6 +272,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree && + migratedSidePanelTabs === sidePanelTabs && migratedSessionTabs === value.sessionTabs && sessionView === value.sessionView ) { @@ -269,6 +284,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sidebar: migratedSidebar, review: migratedReview, fileTree: migratedFileTree, + sidePanelTabs: migratedSidePanelTabs, sessionTabs: migratedSessionTabs, sessionView, } @@ -303,6 +319,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( panelColumn: { width: DEFAULT_PANEL_COLUMN_WIDTH, }, + sidePanelTabs: { + order: [...DEFAULT_SIDE_PANEL_TAB_ORDER], + }, session: { width: DEFAULT_SESSION_WIDTH, }, @@ -724,6 +743,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("panelColumn", { width }) }, }, + sidePanelTabs: { + order: createMemo(() => normalizeSidePanelTabOrder(store.sidePanelTabs?.order)), + move(tab: SidePanelTabID, toIndex: number) { + const order = reorderSidePanelTabs(store.sidePanelTabs?.order ?? DEFAULT_SIDE_PANEL_TAB_ORDER, tab, toIndex) + setStore("sidePanelTabs", { order }) + }, + }, fileTree: { opened: createMemo(() => store.fileTree?.opened ?? true), width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH), diff --git a/packages/app-bundle/overlay/packages/app/src/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index 8fc3582b..b14050db 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -50,10 +50,10 @@ /* Brand yellow, from harmoniqs-ai/app/globals.css (--brand-yellow). The website is the reference; an earlier pass read a different repo and landed on #fff676, which was never the site's colour. */ - --accent: #FFE614; /* brand hue — chip / CTA / fill, both schemes */ - --accent-hover: #F0D600; /* --brand-yellow-hover */ - --accent-ink: #000000; /* text/icon ON a yellow fill — always ink */ - --accent-edge-ink: #000000;/* --brand-yellow-border: every yellow fill takes an ink edge */ + --accent: #ffe614; /* brand hue — chip / CTA / fill, both schemes */ + --accent-hover: #f0d600; /* --brand-yellow-hover */ + --accent-ink: #000000; /* text/icon ON a yellow fill — always ink */ + --accent-edge-ink: #000000; /* --brand-yellow-border: every yellow fill takes an ink edge */ /* ── radius ── The site sets ONE radius for everything — buttons, cards, chips, inputs, @@ -65,11 +65,16 @@ --radius-md: 4px; --radius-lg: 4px; --radius-xl: 4px; - --radius-full: 999px; /* dots and round pills only */ + --radius-full: 999px; /* dots and round pills only */ /* ── spacing (4px grid) ── */ - --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; - --space-5: 20px; --space-6: 24px; --space-8: 32px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; /* ── type ── Faces mirror harmoniqs.ai: DM Sans carries display AND body (the site @@ -80,15 +85,19 @@ --font-mono: "JuliaMono", ui-monospace, SFMono-Regular, Menlo, monospace; --font-julia: "JuliaMono", monospace; - --font-size-2xs: 10px; --font-size-xs: 11px; --font-size-sm: 12px; --font-size-md: 13px; - --font-size-x-small: var(--font-size-xs); /* fix dangling ref */ - --font-weight-emphasis: 600; --font-weight-strong: 650; + --font-size-2xs: 10px; + --font-size-xs: 11px; + --font-size-sm: 12px; + --font-size-md: 13px; + --font-size-x-small: var(--font-size-xs); /* fix dangling ref */ + --font-weight-emphasis: 600; + --font-weight-strong: 650; /* Content sitting on a FIXED dark scrim (media overlays, poster art) does not flip with the scheme — the ground under it is always dark. Mirrors the site's --fg-on-dark. Cream rather than pure white keeps it in the family. */ - --fg-on-dark: #EFEDCD; - --fg-on-dark-muted: #C9C7A6; + --fg-on-dark: #efedcd; + --fg-on-dark-muted: #c9c7a6; --border-width: 1px; @@ -98,22 +107,300 @@ --elev-float: 0 8px 24px rgb(0 0 0 / 0.35); } +#review-panel [data-component="tabs"].preview-tab-strip [data-slot="tabs-list"] { + height: var(--space-8); + gap: var(--space-1); +} + +#review-panel [data-component="tabs"].preview-tab-strip [data-slot="tabs-trigger-wrapper"] { + height: var(--space-6); +} + +#review-panel [data-preview-tab] { + transition: + transform 0.16s ease, + opacity 0.16s ease; +} + +#review-panel [data-preview-tab][data-preview-reorder="source"] { + transform: translateX(var(--preview-drag-offset)); + transition: none; +} + +#review-panel [data-preview-tab][data-preview-reorder="source-remote"] { + opacity: 0; +} + +#review-panel [data-preview-tab][data-preview-reorder="right"] { + transform: translateX(var(--preview-drag-width)); +} + +#review-panel [data-preview-tab][data-preview-reorder="left"] { + transform: translateX(calc(-1 * var(--preview-drag-width))); +} + +html[data-preview-tab-dragging], +html[data-preview-tab-dragging] * { + cursor: default !important; +} + +@media (prefers-reduced-motion: reduce) { + #review-panel [data-preview-tab] { + transition: none; + } +} + +body[data-new-layout] + #review-panel + [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-list"] { + height: var(--space-8); + gap: var(--space-1); +} + +body[data-new-layout] + #review-panel + [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-trigger-wrapper"] { + height: var(--space-6); +} +body[data-new-layout] + #review-panel + [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-list"]::after { + border-bottom: 0; +} +body[data-new-layout] + #review-panel + [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-list"] { + border-bottom: 0; +} + +/* Preview follows VS Code's editor-group hierarchy: each pane keeps its + selected file, while only the keyboard-target pane gives that tab full emphasis. */ +body[data-new-layout] + #review-panel + .preview-pane-leaf:not([data-focused]) + > [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-trigger-wrapper"]:has([data-selected]) { + background-color: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-muted); +} +body[data-new-layout] + #review-panel + .preview-pane-leaf:not([data-focused]) + > [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-trigger"][data-selected] { + color: var(--v2-text-text-muted); +} + +/* Preview's pane tree is structural. The scrollable parent exposes a canvas + that can outgrow the Work Column; no position is read, stored, or restored. */ +#review-panel { + --preview-pane-min-size: 150px; +} +#review-panel .preview-workspace-canvas { + display: flex; + height: 100%; + min-width: 100%; + min-height: 100%; +} +#review-panel .preview-pane-split { + display: flex; + flex: 1 1 auto; + height: 100%; + min-width: var(--preview-pane-min-size); + min-height: var(--preview-pane-min-size); +} +#review-panel .preview-pane-split[data-direction="horizontal"] { + flex-direction: row; + min-width: calc(var(--preview-pane-min-size) * 2); +} +#review-panel .preview-pane-split[data-direction="vertical"] { + flex-direction: column; + min-height: calc(var(--preview-pane-min-size) * 2); +} +#review-panel .preview-pane-branch { + display: flex; + min-width: var(--preview-branch-min-width); + min-height: var(--preview-branch-min-height); +} +#review-panel .preview-pane-divider { + position: relative; + z-index: 2; + flex: 0 0 var(--border-width); + background: var(--v2-border-border-muted); + transition: background-color 0.16s ease; +} +#review-panel .preview-pane-divider::before { + content: ""; + position: absolute; +} +#review-panel .preview-pane-divider[data-direction="horizontal"]::before { + inset: 0 calc(var(--space-1) * -1); +} +#review-panel .preview-pane-divider[data-direction="vertical"]::before { + inset: calc(var(--space-1) * -1) 0; +} +#review-panel .preview-pane-divider[data-direction="horizontal"] { + cursor: col-resize; +} +#review-panel .preview-pane-divider[data-direction="vertical"] { + cursor: row-resize; +} +#review-panel .preview-pane-divider:hover { + background: var(--accent); +} +#review-panel .preview-pane-divider:focus-visible { + background: var(--v2-border-border-focus); + outline: none; +} +#review-panel .preview-pane-divider[data-resizing] { + background: var(--accent); +} +#review-panel .preview-pane-leaf { + position: relative; + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: var(--preview-pane-min-size); + min-height: var(--preview-pane-min-size); + overflow: hidden; + border-radius: 0; + background: var(--v2-background-bg-base); + isolation: isolate; +} +#review-panel .preview-pane-leaf[data-preview-edge-top][data-preview-edge-left] { + border-top-left-radius: var(--radius-md); +} +#review-panel .preview-pane-leaf[data-preview-edge-top][data-preview-edge-right] { + border-top-right-radius: var(--radius-md); +} +#review-panel .preview-pane-leaf[data-preview-edge-bottom][data-preview-edge-left] { + border-bottom-left-radius: var(--radius-md); +} +#review-panel .preview-pane-leaf[data-preview-edge-bottom][data-preview-edge-right] { + border-bottom-right-radius: var(--radius-md); +} +#review-panel .preview-pane-leaf > [data-component="tabs"].preview-tab-strip { + flex: 0 0 auto; + height: auto; +} +#review-panel [data-preview-controls] { + top: var(--space-2); + right: var(--space-3); + gap: var(--space-2); +} +#review-panel .preview-pane-leaf [data-preview-controls] { + top: var(--space-1); + right: var(--space-5); + gap: var(--space-1); + max-width: calc(100% - var(--space-6)); + flex-wrap: wrap; + justify-content: flex-end; +} +#review-panel .preview-pane-content { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} +#review-panel .preview-pane-content > .preview-pane-renderer-mount { + display: contents; +} +#review-panel .preview-pane-renderer-mount > [data-preview-host] { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} +#review-panel .preview-pane-renderer-mount > [data-preview-host] > * { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} +/* A retained host remains attached for editor state, but the inactive one must + not participate in the leaf's flex layout. The Portal wrapper is layout- + transparent so the host's hidden state works in every embedded webview. */ +#review-panel .preview-pane-renderer-mount > [data-preview-host].hidden { + display: none; +} +#review-panel .preview-pane-drop-preview { + position: absolute; + z-index: 10; + pointer-events: none; + border: var(--border-width) solid var(--accent-edge); + border-radius: inherit; + background: var(--accent-fill-soft); +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="left"], +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="right"] { + top: 0; + bottom: 0; + width: 50%; +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="left"] { + left: 0; +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="right"] { + right: 0; +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="top"], +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="bottom"] { + right: 0; + left: 0; + height: 50%; +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="top"] { + top: 0; +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="bottom"] { + bottom: 0; +} +[data-preview-tab-drag-proxy] { + position: fixed; + z-index: 60; + top: 0; + left: 0; + display: flex; + align-items: center; + max-width: min-content; + padding: var(--space-1) var(--space-2); + border: var(--border-width) solid var(--v2-border-border-base); + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--elev-float); + opacity: 0.92; + pointer-events: none; + transform: translate3d(calc(var(--preview-drag-x) + var(--space-2)), calc(var(--preview-drag-y) + var(--space-2)), 0); + transition: opacity 0.16s ease; +} +[data-preview-tab-drag-proxy][data-leaving] { + opacity: 0; +} +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="center"] { + inset: 0; +} + /* ── session status dots ── Four states: green (done, unread), grey (done, seen), yellow (running), red (error). Colour is never the only signal: each dot carries role="img" + aria-label. */ :root, [data-color-scheme="light"] { - --status-idle: #a1a1aa; /* grey — done and seen */ - --status-running: #ca8a04; /* yellow-600 — working */ - --status-done: #16a34a; /* green-600 — done, unread */ - --status-error: #dc2626; /* red-600 */ + --status-idle: #a1a1aa; /* grey — done and seen */ + --status-running: #ca8a04; /* yellow-600 — working */ + --status-done: #16a34a; /* green-600 — done, unread */ + --status-error: #dc2626; /* red-600 */ } [data-color-scheme="dark"] { - --status-idle: #71717a; /* grey */ - --status-running: #eab308; /* yellow-500 */ - --status-done: #4ade80; /* green-400 */ - --status-error: #ef4444; /* red-500 */ + --status-idle: #71717a; /* grey */ + --status-running: #eab308; /* yellow-500 */ + --status-done: #4ade80; /* green-400 */ + --status-error: #ef4444; /* red-500 */ } @media (prefers-color-scheme: dark) { :root:not([data-color-scheme="light"]) { @@ -151,28 +438,27 @@ } /* ── prompt bubble ground — per-scheme REALISATION ── - The user's words, both schemes (Aaron 2026-09-05): on LIGHT the site's - solid yellow chip — --accent fill, --accent-ink, the ink border (--accent- - edge-ink; yellow can't define itself on the near-white ground). On DARK - the chip inverts into a dark box with the FULL-strength yellow border — - the loud yellow outline is the user's signature, one step louder than the - assistant fragments' --accent-edge (45%) hairline — under base ink. */ + On LIGHT the user's words are the INVERSE of the ground — the website's + ink bubble, verbatim (the site is light-only, so the inverse grammar is a + light-mode truth). On DARK any light bubble read harsh at chat scale: + Kate walked the inverse down 100%→85%→70% luminance (7b1ff4e, 1ef7c41) + and it still glared — dark wants LOW contrast, not an inverse. The dark + bubble is therefore the theme's own quiet elevation: layer-02 on the + ground (≈1.5:1, a whisper of lift — stock #2e2e2e on #161616) under full + base ink (≈12:1, unchanged readability). */ :root, [data-color-scheme="light"] { - --prompt-bubble-bg: var(--accent); - --prompt-bubble-ink: var(--accent-ink); - --prompt-bubble-edge: var(--accent-edge-ink); + --prompt-bubble-bg: var(--v2-background-bg-inverse); + --prompt-bubble-ink: var(--v2-text-text-inverse); } [data-color-scheme="dark"] { - --prompt-bubble-bg: var(--v2-background-bg-layer-01); + --prompt-bubble-bg: var(--v2-background-bg-layer-02); --prompt-bubble-ink: var(--v2-text-text-base); - --prompt-bubble-edge: var(--accent); } @media (prefers-color-scheme: dark) { :root:not([data-color-scheme="light"]) { - --prompt-bubble-bg: var(--v2-background-bg-layer-01); + --prompt-bubble-bg: var(--v2-background-bg-layer-02); --prompt-bubble-ink: var(--v2-text-text-base); - --prompt-bubble-edge: var(--accent); } } @@ -233,14 +519,9 @@ /* ── prose fragment cards ── every fragment Amico relays is its OWN bordered card, a message within the chat (Kate 2026-08-25; supersedes the one-container-per-reply segment). Each chunk of a streamed reply arrives - as its own card with its own entrance; history splits identically so the - cards persist. Chips/receipts keep their borderless chip grammar. The - hairline is the theme's default border, the radius the brand 4px. - - (A 2026-09-05 pass seated a dark ground + yellow edge on these cards in - dark mode; reverted the same day at Aaron's call — the dark-box-yellow- - border treatment belongs to the USER's prompt bubble only. The assistant - fragments stay on the theme hairline in both schemes.) */ + as its own card with its own entrance; history splits identically so the + cards persist. Chips/receipts keep their borderless chip grammar. The + hairline is the theme's default border, the radius the brand 4px. */ [data-slot="text-part-body"]:has([data-prose-fragment]) { display: flex; flex-direction: column; @@ -264,12 +545,6 @@ theme scope and the virtualizer relayouts under the captured rect. The --motion-exit-* tokens above stay as the documented grammar should a real FLIP-based exit ever be built. */ - -/* The sticky prompt bubble's lock-in hand-off (one past prompt taking the top - slot) is a WAAPI merge pass in message-timeline.tsx, not CSS: the chip - element must persist across hand-offs — a keyed remount + CSS entrance - strobed under smooth scrolling (Aaron 2026-09-04, reverted same day). */ - @media (prefers-reduced-motion: reduce) { :root { --motion-enter-rise: 0px; @@ -290,7 +565,12 @@ } /* ── motion: quick, quiet, consistent (honors reduced-motion) ── */ -button, [role="button"], a, input, textarea, [data-slot="card"] { +button, +[role="button"], +a, +input, +textarea, +[data-slot="card"] { transition: background-color 0.16s ease, border-color 0.16s ease, @@ -299,7 +579,9 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { transform 0.16s ease; } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { + *, + *::before, + *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } @@ -329,9 +611,13 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { visibly thicker than the sides. The site differentiates with borders, not bevels, so the composer takes the border alone. */ box-shadow: none !important; - transition: border-color 0.16s ease, box-shadow 0.16s ease; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease; +} +[data-component="session-new-composer"] { + min-height: 88px !important; } -[data-component="session-new-composer"] { min-height: 88px !important; } [data-component="session-new-composer"]:focus-within, [data-component="session-composer"]:focus-within, [data-component="prompt-input-v2"]:focus-within { @@ -352,10 +638,17 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { color: var(--accent-ink) !important; border: var(--border-width) solid var(--accent-edge-ink) !important; box-shadow: none !important; - transition: transform 0.16s ease, filter 0.16s ease; + transition: + transform 0.16s ease, + filter 0.16s ease; +} +[data-action="prompt-submit"] svg { + color: var(--accent-ink) !important; +} +[data-action="prompt-submit"]:hover { + filter: brightness(1.06); + transform: translateY(-1px); } -[data-action="prompt-submit"] svg { color: var(--accent-ink) !important; } -[data-action="prompt-submit"]:hover { filter: brightness(1.06); transform: translateY(-1px); } [data-action="prompt-submit"]:disabled { background: transparent !important; border-color: var(--v2-text-text-base) !important; @@ -364,8 +657,13 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { draws at full strength */ opacity: 1 !important; } -[data-action="prompt-submit"]:disabled svg { color: var(--v2-text-text-base) !important; } -[data-action="prompt-submit"]:disabled:hover { filter: none; transform: none; } +[data-action="prompt-submit"]:disabled svg { + color: var(--v2-text-text-base) !important; +} +[data-action="prompt-submit"]:disabled:hover { + filter: none; + transform: none; +} /* accent tag (badge-v2 `variant="accent"`): the shared primitive paints --v2-text-text-contrast over bg-accent, which on dark is LIGHT text on @@ -383,8 +681,9 @@ span[data-component="tag"][data-variant="accent"] { /* the rail: controls use the control corner */ [data-component="sidebar-rail"] button, -[data-component="chat-first-rail"] button { border-radius: var(--radius-md); } - +[data-component="chat-first-rail"] button { + border-radius: var(--radius-md); +} /* solver-switch banner (opencode#78 follow-up) — narrates the server restart a solver switch triggers. Progress reads NEUTRAL (yellow may never be a @@ -407,7 +706,10 @@ span[data-component="tag"][data-variant="accent"] { font-size: var(--font-size-sm); font-weight: var(--font-weight-emphasis); box-shadow: var(--elev-float); - transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease; + transition: + background-color 0.16s ease, + border-color 0.16s ease, + color 0.16s ease; } [data-component="amicode-solver-switch"][data-phase="ready"] { background: var(--accent); @@ -430,8 +732,13 @@ span[data-component="tag"][data-variant="accent"] { } /* the global prefers-reduced-motion reset above collapses this duration. */ @keyframes amc-solver-switch-pulse { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } } /* ── Onboarding walkthrough spotlight (session-tour.tsx) ─────────────────── @@ -467,8 +774,11 @@ span[data-component="tag"][data-variant="accent"] { border: 1px solid var(--accent); border-radius: var(--radius-md); /* Eases between elements rather than teleporting. */ - transition: top 0.22s cubic-bezier(0.2, 0, 0, 1), left 0.22s cubic-bezier(0.2, 0, 0, 1), - width 0.22s cubic-bezier(0.2, 0, 0, 1), height 0.22s cubic-bezier(0.2, 0, 0, 1); + transition: + top 0.22s cubic-bezier(0.2, 0, 0, 1), + left 0.22s cubic-bezier(0.2, 0, 0, 1), + width 0.22s cubic-bezier(0.2, 0, 0, 1), + height 0.22s cubic-bezier(0.2, 0, 0, 1); /* Fades in once as the highlight lands. Keyed on the stop, so it plays per stop and not on the measure tick. */ animation: amc-tour-arrive 0.25s ease-out both; diff --git a/packages/app-bundle/overlay/packages/app/src/index.css b/packages/app-bundle/overlay/packages/app/src/index.css index 2f1aa408..404975bb 100644 --- a/packages/app-bundle/overlay/packages/app/src/index.css +++ b/packages/app-bundle/overlay/packages/app/src/index.css @@ -396,3 +396,21 @@ [data-slot="thought-rail-dot"][data-state="done"] { transition: opacity 150ms ease-out; } + +/* Preview tabs mirror the V2 file tree: neutral icons until selection, with no hover recoloring. */ +.preview-tab-strip [data-slot="tabs-trigger"] .tab-fileicon-color { + display: none !important; +} + +.preview-tab-strip [data-slot="tabs-trigger"] .tab-fileicon-mono { + display: block !important; + color: var(--v2-icon-icon-muted); +} + +.preview-tab-strip [data-slot="tabs-trigger"][data-selected] .tab-fileicon-color { + display: block !important; +} + +.preview-tab-strip [data-slot="tabs-trigger"][data-selected] .tab-fileicon-mono { + display: none !important; +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session.tsx b/packages/app-bundle/overlay/packages/app/src/pages/new-session.tsx index 1c0c60f6..ac3d00ec 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/new-session.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session.tsx @@ -1,7 +1,9 @@ import { createPromptProjectController } from "@/components/prompt-project-selector" +import { SessionPreviewTab } from "@/components/session/session-preview-tab" import { useTitlebarControlMount } from "@/components/titlebar" import { useSettings } from "@/context/settings" -import { createEffect, createResource, onMount } from "solid-js" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { useLocation } from "@solidjs/router" import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view" @@ -32,6 +34,7 @@ export default function NewSessionPage() { }, }) const location = useLocation() + const [previewFile, setPreviewFile] = createSignal(null) // amicode: register the Amico ops commands here too — the draft page has no // palette, so restart/update-memory are reachable via their direct keybinds. @@ -46,6 +49,12 @@ export default function NewSessionPage() { // amicode(deck): label the framing pane tab; the draftId rides the search // so the shell can rebuild this pane with its draft text intact. postRouteInfo(`${location.pathname}${location.search}`, "New session") + const onPreviewFile = (event: MessageEvent) => { + const data = event.data as { source?: string; kind?: string; path?: string } | undefined + if (data?.source === "amicode" && data.kind === "preview-file" && data.path) setPreviewFile(data.path) + } + window.addEventListener("message", onPreviewFile) + onCleanup(() => window.removeEventListener("message", onPreviewFile)) }) const ready = Promise.resolve() const [suspendUntilPromptReady] = createResource( @@ -57,12 +66,35 @@ export default function NewSessionPage() {
{suspendUntilPromptReady()} -
+
+ + +
) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts index 64f77e59..ef5806dd 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.test.ts @@ -168,6 +168,26 @@ describe("createSessionTabs", () => { }) }) + test("falls back to Home when Files Changed is available but closed", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: [] as string[], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => false, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("home") + dispose() + }) + }) + test("exposes the Open File tab without treating it as a file tab", () => { createRoot((dispose) => { const [state] = createStore({ @@ -252,10 +272,7 @@ describe("createSessionTabs", () => { }) // Step 3: re-open context — simulates openSessionContext calling tabs.open("context") - const afterReopen = openSessionTab( - { tabs: afterClose.tabs, preview: afterClose.preview }, - "context", - ) + const afterReopen = openSessionTab({ tabs: afterClose.tabs, preview: afterClose.preview }, "context") expect(afterReopen.tabs.all).toContain("context") expect(afterReopen.tabs.active).toBe("context") @@ -277,7 +294,10 @@ describe("createSessionTabs", () => { test("pulseInspector is closable and reopenable", () => { // Step 1: pulseInspector is open and active - const initial = { tabs: { all: ["pulseInspector"], active: "pulseInspector" as string | undefined }, preview: undefined } + const initial = { + tabs: { all: ["pulseInspector"], active: "pulseInspector" as string | undefined }, + preview: undefined, + } createRoot((dispose) => { const tabs = createMemo(() => ({ active: () => initial.tabs.active, all: () => initial.tabs.all })) @@ -310,10 +330,7 @@ describe("createSessionTabs", () => { }) // Step 3: re-open pulseInspector - const afterReopen = openSessionTab( - { tabs: afterClose.tabs, preview: afterClose.preview }, - "pulseInspector", - ) + const afterReopen = openSessionTab({ tabs: afterClose.tabs, preview: afterClose.preview }, "pulseInspector") expect(afterReopen.tabs.all).toContain("pulseInspector") expect(afterReopen.tabs.active).toBe("pulseInspector") 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 index c3104919..16b29e57 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/helpers.ts @@ -44,7 +44,9 @@ export const createSessionTabs = (input: TabsInput) => { 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 pulseInspectorOpen = createMemo( + () => input.tabs().active() === "pulseInspector" || input.tabs().all().includes("pulseInspector"), + ) const homeOpen = createMemo(() => input.tabs().active() === "home" || input.tabs().all().includes("home")) const panelTabs = createMemo( () => { @@ -53,7 +55,15 @@ export const createSessionTabs = (input: TabsInput) => { .tabs() .all() .flatMap((tab) => { - if (tab === "context" || tab === "review" || tab === "vault" || tab === "home" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector") return [] + if ( + tab === "context" || + tab === "review" || + tab === "vault" || + tab === "home" || + 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 [] 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 index 10017048..07cd937c 100644 --- 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 @@ -21,4 +21,10 @@ describe("work column is vault-free (amicode#105)", () => { expect(source).not.toContain('value="vault"') expect(source).not.toContain("vaultOpen") }) + + test("persists and sorts only the named surface tabs", () => { + expect(source).toContain("layout.sidePanelTabs.order()") + expect(source).toContain("SortableSidePanelSurfaceTab") + expect(source).toContain("handleSurfaceTabDragEnd") + }) }) 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 index a2cfdc4c..4793a11e 100644 --- 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 @@ -1,8 +1,20 @@ -import { For, Match, Show, Switch, createEffect, createMemo, createResource, createSignal, on, onCleanup, type JSX } from "solid-js" +import { + For, + Match, + Show, + Switch, + createEffect, + createMemo, + createResource, + 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 { isSortable, useSortable } 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" @@ -52,7 +64,14 @@ import { base64Encode } from "@opencode-ai/core/util/encode" 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 { + 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" @@ -61,6 +80,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" +import type { SidePanelTabID } from "@/context/layout-side-panel-tabs" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { @@ -78,6 +98,31 @@ import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/ses type PulseInspectorStage = "optimization" | "calibration" | "compilation" +function SortableSidePanelSurfaceTab(props: { + tab: SidePanelTabID + index: () => number + visible: () => boolean + children: JSX.Element +}) { + const sortable = useSortable({ + get id() { + return props.tab + }, + get index() { + return props.index() + }, + get disabled() { + return props.tab === "home" + }, + }) + + return ( +
+ {props.children} +
+ ) +} + function PulseInspectorContent() { const bridge = useInspectorBridge() const [stage, setStage] = createSignal("optimization") @@ -118,14 +163,18 @@ function PulseInspectorContent() { disabled > Calibration - Soon + + Soon +
@@ -203,10 +252,7 @@ function HomeTabContent() { if (!conn) return {} const out: Record = {} for (const w of widgetInfos()) - out[w.id] = new URL( - `/amicode/widget-frame?id=${encodeURIComponent(w.id)}&h=${w.hash}`, - conn.http.url, - ).toString() + out[w.id] = new URL(`/amicode/widget-frame?id=${encodeURIComponent(w.id)}&h=${w.hash}`, conn.http.url).toString() return out }) @@ -240,9 +286,7 @@ function HomeTabContent() { when={widgetInfos().length > 0 && dashboard()} fallback={
-
- No widgets yet. Ask Amico to create one for you. -
+
No widgets yet. Ask Amico to create one for you.
} > @@ -339,6 +383,12 @@ export function SessionSidePanel(props: { // the bridge message handler can set it from outside the side panel. const previewFile = createMemo(() => view().previewFile.get()) + createEffect( + on(previewFile, (path) => { + if (path) tabs().setActive(SESSION_PREVIEW_TAB) + }), + ) + const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { @@ -402,7 +452,7 @@ export function SessionSidePanel(props: { tabs, pathFromTab: file.pathFromTab, normalizeTab, - review: reviewTab, + review: () => reviewTab() && reviewTabOpen(), hasReview: props.canReview, fileBrowser: () => !!props.fileBrowserState, }) @@ -414,6 +464,17 @@ export function SessionSidePanel(props: { const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab + // Keep every named trigger mounted. Kobalte validates a controlled value + // against its registered collection and otherwise falls back to the first tab. + // Unmounting Preview while its preview-file event selects it therefore rewrites + // the controlled value to Home before Preview can register. + const surfaceTabVisible = (tab: SidePanelTabID) => { + if (tab === "home") return true + if (tab === "review") return reviewTab() && props.canReview() && reviewTabOpen() + if (tab === "context") return contextOpen() + if (tab === "pulseInspector") return pulseInspectorOpen() + return previewOpen() + } const fileTreeTab = () => layout.fileTree.tab() @@ -460,7 +521,13 @@ export function SessionSidePanel(props: { }) const fileBrowserVisible = createMemo(() => { const active = activeTab() - return active !== "review" && active !== "context" && active !== "home" && active !== "empty" && active !== SESSION_PREVIEW_TAB + return ( + active !== "review" && + active !== "context" && + active !== "home" && + active !== "empty" && + active !== SESSION_PREVIEW_TAB + ) }) // Markdown files for the Preview tab — check both git diffs and tool-edit history @@ -544,6 +611,14 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } + const handleSurfaceTabDragEnd = (event: any) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + + const tab = source.id.toString() as SidePanelTabID + layout.sidePanelTabs.move(tab, source.index) + } + createEffect(() => { if (!file.ready()) return @@ -641,7 +716,10 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > - +
Home
@@ -651,6 +729,7 @@ export function SessionSidePanel(props: {
@@ -667,7 +746,12 @@ export function SessionSidePanel(props: { docs/adr/0001). Do not re-add a tab here: two hosts mirrored through two stores was the desync this column's toggle got blamed for. */} -
+
-
+
tabs().close("pulseInspector")} > -
- -
Pulse Inspector
-
-
+
+ +
Pulse Inspector
+
+
-
+
- +
@@ -813,18 +904,25 @@ export function SessionSidePanel(props: { - + - - -
- -
-
-
+ +
+ +
+
{(tab) => } @@ -847,196 +945,229 @@ export function SessionSidePanel(props: { } > - - event.target instanceof Element && - (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || - !!event.target.closest(".session-review-v2-open-in-app-slot")), - }), - ]} - modifiers={[ - RestrictToHorizontalAxis, - RestrictToElement.configure({ element: () => tabList ?? null }), - ]} - plugins={(defaults) => [ - ...defaults.filter((plugin) => plugin !== Accessibility), - AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - const source = event.operation.source - if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return - tabs().move(source.id.toString(), source.index) - }} - > - -
- { - tabList = el - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - {(toggle) => ( -
- {toggle()(false)} -
- )} -
- -
- -
Home
+ +
+ { + tabList = el + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + + {(toggle) => ( +
+ {toggle()(false)}
- - - - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - setReviewTabOpen(false)} - aria-label={language.t("common.closeTab")} - /> - - } - > -
- -
- {props.hasReview() - ? "Files Changed" - : language.t("session.tab.review")} -
- -
{props.reviewCount()}
-
-
-
-
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} + )} + + + event.target instanceof Element && + !!event.target.closest('[data-slot="tabs-trigger-close-button"]'), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={handleSurfaceTabDragEnd} + > + + {(tab, index) => ( + surfaceTabVisible(tab)} > - tabs().close("pulseInspector")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("pulseInspector")} - > -
- -
Pulse Inspector
-
-
-
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - tabs().close(SESSION_PREVIEW_TAB)} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close(SESSION_PREVIEW_TAB)} - > -
- -
Preview
-
-
-
+ + + +
+ +
Home
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + setReviewTabOpen(false)} + aria-label={language.t("common.closeTab")} + /> + + } + > +
+ +
+ {props.hasReview() ? "Files Changed" : language.t("session.tab.review")} +
+ +
{props.reviewCount()}
+
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("pulseInspector")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("pulseInspector")} + > +
+ +
Pulse Inspector
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close(SESSION_PREVIEW_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_PREVIEW_TAB)} + > +
+ +
Preview
+
+
+
+
+ + )} + + + + event.target instanceof Element && + (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || + !!event.target.closest(".session-review-v2-open-in-app-slot")), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} + > {(tab) => ( )} -
-
- -
-
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > - -
+ + +
+
- - -
- {props.reviewPanel()} -
-
- - - - - - - - - -
-
- -
- {language.t("session.files.selectToOpen")} -
+
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + +
+
+ + +
+ {props.reviewPanel()} +
+
+ + + + + + + + + +
+
+ +
+ {language.t("session.files.selectToOpen")}
- - - - - -
- -
-
-
+
+
+
- - - - - + + +
+ +
+
+
- - -
- -
-
-
+ + + + + + + +
+ +
+
- -
- previewTab(file.tab(path))} - onSelectPermanent={(path) => openTab(file.tab(path))} - filterRef={(element) => (fileFilter = element)} - /> -
-
- - + +
+ previewTab(file.tab(path))} + onSelectPermanent={(path) => openTab(file.tab(path))} + filterRef={(element) => (fileFilter = element)} + /> +
+
+
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 index 600b4da3..cd1879bb 100644 --- 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 @@ -879,34 +879,6 @@ export function MessageTimeline(props: { // What the bubble actually shows: override (from click) takes priority const activeBubble = () => bubbleOverride() ?? visiblePromptBubble() - // The bubble lock — a smooth MERGE on hand-off (Aaron 2026-09-04). The chip - // element persists; when the message locked at top changes (scroll hand-off, - // click, send), the text swap underneath is masked by a gentle opacity + - // translate dip via WAAPI — the new prompt glides over the old in one - // soft 240ms pass. No remount, no exit machinery, nothing to strobe under - // smooth scrolling. Honors prefers-reduced-motion. - let bubbleChip: HTMLButtonElement | undefined - let lockedBubbleId: string | undefined - createEffect(() => { - const bubble = activeBubble() - if (!bubble) { - lockedBubbleId = undefined - return - } - if (bubble.messageId === lockedBubbleId) return - lockedBubbleId = bubble.messageId - const el = bubbleChip - if (!el || typeof el.animate !== "function") return - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return - el.animate( - [ - { opacity: 0.45, transform: "translateY(2px)" }, - { opacity: 1, transform: "none" }, - ], - { duration: 240, easing: "cubic-bezier(0.2, 0, 0.2, 1)" }, - ) - }) - // 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 @@ -2603,8 +2575,7 @@ export function MessageTimeline(props: { }} /> {/* amicode#271: bubble inside the header — naturally below the - title row + chip rail. The element PERSISTS across hand-offs; - the lock-in merge runs via WAAPI in the effect below. */} + title row + chip rail */} {(bubble) => (
@@ -2636,8 +2606,7 @@ export function MessageTimeline(props: {
- {/* amicode#271: no-header fallback (new untitled sessions only) — - same persistent chip, same WAAPI merge */} + {/* amicode#271: no-header fallback (new untitled sessions only) */} {(_) => { const bubble = () => activeBubble()! @@ -2649,21 +2618,20 @@ export function MessageTimeline(props: { >
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css index d86a6d10..f1871c1a 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css @@ -6,12 +6,6 @@ --markdown-shell-code-border: var(--v2-border-border-muted); --markdown-shell-code-color: var(--v2-text-text-base); --markdown-shell-code-token-color: currentColor; - /* the slab (Aaron 2026-09-04): fenced code leaves the prose-card grammar — - a full-bleed raised surface with a language label. Scheme-quiet ground so - shiki tokens stay readable in both themes. */ - --markdown-slab-bg: var(--v2-background-bg-layer-02); - --markdown-slab-border: var(--v2-border-border-muted); - --markdown-slab-label: var(--v2-text-text-faint); /* Reset & Base Typography */ min-width: 0; @@ -191,55 +185,6 @@ position: relative; } - /* ── the slab — fenced code inside a prose fragment ── - Full-bleed across the fragment's 14px gutters (negative margins), the - fragment radius' step down (md), the quiet layer-02 ground in BOTH - schemes — code reads as an object on the page, not another text card. - The language label sits top-right (attr-driven, no component change); - the copy button keeps its corner. Scoped to prose fragments: tool-output - and skill-file code keep the inline-card treatment. */ - [data-prose-fragment] [data-component="markdown-code"] { - margin: 2px -14px; - padding: 24px 0 4px; - background: var(--markdown-slab-bg); - border: var(--border-width) solid var(--markdown-slab-border); - border-radius: var(--radius-md); - } - - [data-prose-fragment] [data-component="markdown-code"]::before { - content: attr(data-language); - position: absolute; - top: 6px; - right: 36px; /* clear of the copy button's corner */ - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: var(--font-size-2xs); - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--markdown-slab-label); - pointer-events: none; - } - - [data-prose-fragment] [data-component="markdown-code"] .shiki { - margin: 0; - padding: 8px 14px 12px; - background: transparent; - border: none; - border-radius: 0; - } - - [data-prose-fragment] [data-component="markdown-code"][data-code-kind="shell"] { - background: var(--markdown-shell-code-background); - border-color: var(--markdown-shell-code-border); - } - - /* the shell kind's own .shiki rules sit later in the file and would paint a - nested box inside the slab — silence them; the slab carries the ground */ - [data-prose-fragment] [data-component="markdown-code"][data-code-kind="shell"] .shiki { - background: transparent; - border: none; - border-radius: 0; - } - [data-component="markdown-code"][data-code-kind="shell"] .shiki { background: var(--markdown-shell-code-background); border-color: var(--markdown-shell-code-border); diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.css b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.css index 67c644b9..8b8167ea 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.css +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.css @@ -155,13 +155,13 @@ } /* The prompt bubble, from the website's /amicode chat (harmoniqs-ai - app/components/demo/parts.jsx, `Turn`), now a SOLID YELLOW chip (Aaron - 2026-09-03): --accent fill + --accent-ink in both schemes — the site's - black-on-yellow chip grammar. The edge is seated per scheme in - design-polish.css (--prompt-bubble-edge): the site's ink border on - light (yellow can't define itself on the near-white ground), none on - dark (solid yellow against the deep ground). Fallback = raw inverse, - for consumers without the app skin. Padding is the site's px-4 py-2.5. */ + app/components/demo/parts.jsx, `Turn`): the user's words are the INVERSE + of the ground — bg-deep/text-bg there. The app tokens + (--prompt-bubble-*, design-polish.css) realise that per scheme: the + site's ink bubble verbatim on light; on dark a quiet elevated layer + under base ink — any light bubble glared at chat scale on a dark field. + Fallback = raw inverse, for consumers without the app skin. + Padding is the site's px-4 py-2.5. */ [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; @@ -169,15 +169,15 @@ overflow: hidden; background: var(--prompt-bubble-bg, var(--v2-background-bg-inverse)); color: var(--prompt-bubble-ink, var(--v2-text-text-inverse)); - border: var(--border-width, 1px) solid var(--prompt-bubble-edge, transparent); + border: none; padding: 10px 16px; border-radius: var(--radius-lg); - /* On the yellow fill the scheme-tuned syntax colours fail contrast — - each was picked against the page ground. References take the site's - File-chip grammar: mono on a soft chip of the bubble's own ink (the - site's `bg-fg/8`), so the chip darkens the yellow slightly and the - mono stays black-on-yellow. */ + /* On the flipped ground the scheme-tuned syntax colours fail contrast — + each was picked against the page ground, and this bubble is its + inverse. References instead take the site's File-chip grammar: mono on + a soft chip of the bubble's own foreground (the site's `bg-fg/8`, + mixed from text-inverse here so it flips with the bubble). */ [data-highlight="file"], [data-highlight="agent"] { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); @@ -670,180 +670,6 @@ } } -/* The docket (Aaron 2026-09-04) — the collapsed row's evidence tokens: files - with ±, search patterns with repeat counts, touched dirs. Inline after the - verb label, clipped by the row's own ellipsis; tokens never shrink (a - clipped tail reads better than squeezed tokens). */ -[data-slot="context-tool-group-docket"] { - display: inline-flex; - align-items: center; - gap: 10px; - min-width: 0; - overflow: hidden; - font-size: var(--font-size-sm); -} - -[data-slot="docket-token"] { - display: inline-flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - min-width: 0; - max-width: 200px; - overflow: hidden; - white-space: nowrap; - color: var(--v2-text-text-muted); -} - -[data-slot="docket-token"] .docket-file-icon { - width: 14px; - height: 14px; - flex-shrink: 0; -} - -[data-slot="docket-token-name"] { - overflow: hidden; - text-overflow: ellipsis; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 0.92em; - color: var(--v2-text-text-base); -} - -[data-slot="docket-token"][data-kind="dir"] [data-slot="docket-token-name"], -[data-slot="docket-token"][data-kind="pattern"] [data-slot="docket-token-name"] { - color: var(--v2-text-text-muted); -} - -[data-slot="docket-token"][data-kind="more"] { - color: var(--v2-text-text-faint); - font-size: 0.92em; -} - -[data-slot="docket-diff"] { - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 0.85em; - font-variant-numeric: tabular-nums; - flex-shrink: 0; -} - -[data-slot="docket-diff"][data-sign="add"] { - color: var(--v2-state-fg-success); -} - -[data-slot="docket-diff"][data-sign="del"] { - color: var(--v2-state-fg-danger); -} - -[data-slot="docket-count"] { - color: var(--v2-text-text-faint); - font-size: 0.85em; - font-variant-numeric: tabular-nums; -} - -[data-slot="docket-failed"] { - color: var(--v2-state-fg-danger); -} - -/* ── expanded dropdown rows (Aaron 2026-09-05) ── - File targets are clickable buttons (icon + mono name, full path as the - tooltip, underline hover, visible focus). Shell rows carry a status dot, - the $ prompt, wall duration, exit code when it isn't 0, and the tool's - own one-line output preview. */ -[data-slot="docket-row-file"] { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - max-width: 100%; - overflow: hidden; - background: none; - border: none; - padding: 0; - cursor: pointer; - font: inherit; - color: var(--v2-text-text-base); - border-radius: var(--radius-sm); -} - -[data-slot="docket-row-file"]:hover [data-slot="docket-token-name"] { - text-decoration: underline; - text-underline-offset: 2px; - color: var(--v2-text-text-strong); -} - -[data-slot="docket-row-file"]:focus-visible { - outline: var(--border-width) solid var(--v2-border-border-focus); - outline-offset: 1px; -} - -[data-cmd-row] [data-slot="basic-tool-tool-info-structured"] { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -[data-cmd-row] [data-slot="basic-tool-tool-info-main"] { - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -[data-slot="cmd-dot"] { - width: 6px; - height: 6px; - flex-shrink: 0; - border-radius: var(--radius-full); - background: var(--v2-text-text-faint); -} - -[data-slot="cmd-dot"][data-state="running"] { - background: var(--status-running); - animation: cmd-dot-pulse 1.2s ease-in-out infinite; -} - -[data-slot="cmd-dot"][data-state="error"] { - background: var(--v2-state-fg-danger); -} - -@keyframes cmd-dot-pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.35; - } -} - -[data-slot="cmd-prompt"] { - color: var(--v2-text-text-faint); - margin-right: 2px; -} - -[data-slot="cmd-duration"], -[data-slot="cmd-exit"] { - flex-shrink: 0; - font-size: var(--font-size-xs); - font-variant-numeric: tabular-nums; - color: var(--v2-text-text-faint); -} - -[data-slot="cmd-exit"] { - color: var(--v2-state-fg-danger); -} - -[data-slot="cmd-preview"] { - margin-left: 14px; /* clears the 6px dot + 8px gap */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 0.92em; - color: var(--v2-text-text-faint); -} - [data-component="context-tool-group-list"] { padding-top: 0; padding-right: 0; @@ -1454,18 +1280,8 @@ color: var(--v2-text-text-muted); } - /* The answer is the user's words — it renders as the prompt bubble's - yellow chip (same tokens, same edge seating in design-polish.css), - right-aligned to the user's side of the card. */ [data-slot="answer-text"] { - width: fit-content; - max-width: 100%; - margin-left: auto; - background: var(--prompt-bubble-bg, var(--v2-background-bg-inverse)); - color: var(--prompt-bubble-ink, var(--v2-text-text-inverse)); - border: var(--border-width, 1px) solid var(--prompt-bubble-edge, transparent); - border-radius: var(--radius-lg); - padding: var(--space-1) var(--space-3); + color: var(--v2-text-text-base); } } } diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx index dd98294e..4d932c80 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx @@ -1,6 +1,6 @@ import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner" import { ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking" -import { shellRowDetail, shellRowLabel } from "@opencode-ai/ui/amicode-shell-row" +import { shellRowLabel } from "@opencode-ai/ui/amicode-shell-row" import { sessionHasAmicodeParts } from "@opencode-ai/ui/amicode-rail-gate" import { amicoBrainRef, emitAmicoBrainHover } from "@opencode-ai/ui/amicode-brain-ref" import { copyTextToClipboard } from "../util/clipboard" @@ -696,7 +696,6 @@ import { } from "./message-part-groups" import { parseDiffSentinel } from "@opencode-ai/ui/amicode-receipt" import { editRowDiff, editRowFilePath, editRowLabel } from "@opencode-ai/ui/amicode-edit-row" -import { contextDocket, editDocket, shellDocket, type DocketToken } from "@opencode-ai/ui/amicode-docket" import { collapseReceiptRuns, receiptRunKey, @@ -1147,63 +1146,6 @@ function contextToolSummary(parts: ToolPart[]) { return { read, search, list } } -/* The docket (Aaron 2026-09-04): collapsed group rows carry their evidence — - file tokens with ±, search patterns with repeat counts, touched dirs — - bounded to the pure helpers' max with a `+N more` tail. Tokens render in - the row itself; the dropdown below still carries the full per-part detail. */ -function GroupDocket(props: { tokens: DocketToken[]; more: number }) { - return ( - - - {(token) => { - if (token.kind === "file") { - return ( - - - {token.name} - - - +{token.additions} - - - - - −{token.deletions} - - - - ) - } - if (token.kind === "pattern") { - return ( - - {token.text} - 1}> - ×{token.count} - - - ) - } - return ( - - {token.text} - - ) - }} - - 0}> - - +{props.more} - - - - ) -} - function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -1408,7 +1350,6 @@ export function ContextToolGroup(props: { !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), ) const summary = createMemo(() => contextToolSummary(props.parts)) - const docket = createMemo(() => contextDocket(props.parts)) const handleOpenChange = (value: boolean) => { if (props.open === undefined) setLocalOpen(value) props.onOpenChange?.(value) @@ -1445,36 +1386,29 @@ export function ContextToolGroup(props: { data-slot="context-tool-group-summary" class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base" > - 0 || docket().more > 0} - fallback={ - - } - > - - + @@ -1488,15 +1422,6 @@ export function ContextToolGroup(props: { const running = createMemo( () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", ) - // A read row targets a file — make it openable (Aaron 2026-09-05): - // the row's target becomes a button that opens it in the editor. - const filePath = createMemo(() => { - const part = partAccessor() - if (part.tool !== "read") return - const input = (part.state.input ?? {}) as Record - const p = typeof input.filePath === "string" ? input.filePath : undefined - return p - }) return (
@@ -1507,15 +1432,8 @@ export function ContextToolGroup(props: { {trigger().title} - - {trigger().subtitle} - - } - > - + + {trigger().subtitle} @@ -1537,23 +1455,6 @@ export function ContextToolGroup(props: { ) } -/** A file target inside an expanded dropdown: icon + name, clickable — opens - * in the editor. The full path rides the tooltip; the hover is an underline, - * never a color-only signal. */ -function DocketRowFile(props: { path: string; name?: string }) { - return ( - - ) -} - // AMICODE (spec B): consecutive bash commands collapse into one row so a long // run of shell calls doesn't dominate the timeline. Mirrors ContextToolGroup's // markup (reuses its CSS slots) with a shell label + per-command list. A lone @@ -1565,7 +1466,6 @@ export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSiz !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), ) const count = createMemo(() => props.parts.length) - const tally = createMemo(() => shellDocket(props.parts)) const handleOpenChange = (value: boolean) => { setOpen(value) props.onSizeChange?.() @@ -1601,13 +1501,7 @@ export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSiz data-slot="context-tool-group-summary" class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base" > - {tally().commands} {tally().commands === 1 ? "command" : "commands"} - 0}> - {" "} - - · {tally().failed} {tally().failed === 1 ? "failed" : "failed"} - - + {count()} {count() === 1 ? "command" : "commands"} @@ -1618,44 +1512,26 @@ export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSiz {(partAccessor) => { const cmd = createMemo(() => shellCommandText(partAccessor())) - const detail = createMemo(() => shellRowDetail(partAccessor())) const running = createMemo( () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", ) const errored = createMemo(() => partAccessor().state.status === "error") return ( -
+
- - - - $ {cmd()} - + + {cmd()} - - {formatCmdDuration(detail().durationMs!)} - - - exit {detail().exit} - - + failed
- -
{detail().preview}
-
@@ -1670,16 +1546,6 @@ export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSiz ) } -/** Coarse wall duration for a command row: 0.4s / 12.3s / 1m 03s. */ -function formatCmdDuration(ms: number): string { - if (ms < 0) return "" - const seconds = ms / 1000 - if (seconds < 60) return `${seconds.toFixed(1)}s` - const minutes = Math.floor(seconds / 60) - const rest = Math.round(seconds - minutes * 60) - return `${minutes}m ${String(rest).padStart(2, "0")}s` -} - // AMICODE (spec B shape): consecutive file mutations (edit/write/patch) collapse // into one row so a long authoring run doesn't dominate the timeline. Mirrors // ShellToolGroup's markup (reuses its CSS slots) with per-file rows that keep @@ -1709,9 +1575,6 @@ export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSize .map((part) => editRowDiff(part)) .filter((diff): diff is { additions: number; deletions: number } => !!diff), ) - // The docket: per-file tokens with their own ±, bounded — the row carries - // its evidence, the aggregate pill stays only as the zero-file fallback. - const docket = createMemo(() => editDocket(props.parts)) const handleOpenChange = (value: boolean) => { setOpen(value) props.onSizeChange?.() @@ -1742,25 +1605,15 @@ export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSize data-slot="context-tool-group-summary" class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base" > - 0 || docket().more > 0} - fallback={ - <> - {count()} {count() === 1 ? "change" : "changes"} - 0}> - {" "} - in {fileCount()} {fileCount() === 1 ? "file" : "files"} - - 0}> - {" "} - - - - } - > - + {count()} {count() === 1 ? "change" : "changes"} + 0}> + {" "} + in {fileCount()} {fileCount() === 1 ? "file" : "files"} + 0}> + +
@@ -1770,7 +1623,6 @@ export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSize {(partAccessor) => { const label = createMemo(() => editRowLabel(partAccessor())) - const path = createMemo(() => editRowFilePath(partAccessor())) const diff = createMemo(() => editRowDiff(partAccessor())) const running = createMemo( () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", @@ -1783,16 +1635,9 @@ export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSize
- - {label()} - - } - > - - + + {label()} + {(d) => } diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts index e9df968e..bfb383c3 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { clampShellLabel, shellRowDetail, shellRowLabel, SHELL_ROW_MAX } from "./shell-row" +import { clampShellLabel, shellRowLabel, SHELL_ROW_MAX } from "./shell-row" // Regression coverage for "a constant error not going away" (2026-07-29). // @@ -58,27 +58,3 @@ describe("shell row label", () => { expect(clampShellLabel("'Install the dependencies'")).toBe("Install the dependencies") }) }) - -describe("shellRowDetail", () => { - test("exit surfaces only when it isn't 0", () => { - expect(shellRowDetail({ state: { metadata: { exit: 0 } } }).exit).toBeUndefined() - expect(shellRowDetail({ state: { metadata: { exit: 64 } } }).exit).toBe(64) - }) - - test("duration comes from state.time when both ends exist", () => { - expect( - shellRowDetail({ state: { time: { start: 1000, end: 4250 }, metadata: {} } }).durationMs, - ).toBe(3250) - expect(shellRowDetail({ state: { time: { start: 1000 }, metadata: {} } }).durationMs).toBeUndefined() - }) - - test("output preview is the clamped first line", () => { - const detail = shellRowDetail({ state: { metadata: { output: "line one\nline two" } } }) - expect(detail.preview).toBe("line one") - expect(shellRowDetail({ state: { metadata: { output: "x".repeat(SHELL_ROW_MAX + 10) } } }).preview?.endsWith("…")).toBe(true) - }) - - test("a pending part yields an empty detail", () => { - expect(shellRowDetail({})).toEqual({}) - }) -}) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts index 8b4cde4f..400bfed9 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/shell-row.ts @@ -22,37 +22,9 @@ export interface ShellRowPartLike { state?: { input?: Record title?: unknown - metadata?: Record - time?: { start?: number; end?: number } } } -export interface ShellRowDetail { - exit?: number - durationMs?: number - preview?: string -} - -/** The row's evidence beyond the command itself (Aaron 2026-09-05): exit code - * when it isn't 0, wall duration from state.time, and the tool's own output - * preview (first line only — the row is a glance, not a terminal). Everything - * optional; a pending part yields an empty detail. */ -export function shellRowDetail(part: ShellRowPartLike): ShellRowDetail { - const detail: ShellRowDetail = {} - const metadata = part.state?.metadata ?? {} - const exit = metadata.exit - if (typeof exit === "number" && exit !== 0) detail.exit = exit - const { start, end } = part.state?.time ?? {} - if (typeof start === "number" && typeof end === "number" && end >= start) { - detail.durationMs = end - start - } - const output = metadata.output - if (typeof output === "string" && output.trim() !== "") { - detail.preview = clampShellLabel(output, SHELL_ROW_MAX) - } - return detail -} - /** Trim, take the first line, drop wrapping quotes, and elide past SHELL_ROW_MAX. */ export function clampShellLabel(raw: string, max: number = SHELL_ROW_MAX): string { let s = raw.split("\n")[0]!.trim() diff --git a/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx b/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx index fc06a303..2750a3dd 100644 --- a/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/components/amicode-shell-row.tsx @@ -1,3 +1,3 @@ // AMICODE: re-export shim (same wildcard-export pattern as amicode-card.tsx). // Logic lives in ../amicode/shell-row.ts. -export { shellRowDetail, shellRowLabel } from "../amicode/shell-row" +export { shellRowLabel } from "../amicode/shell-row" From 2cdf1269c0795ee35454aeff9158386449e64191 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 20:29:29 -0400 Subject: [PATCH 5/8] chore(app-bundle): sync preview rail snapping --- packages/app-bundle/manifest.json | 4 +- .../session/session-preview-tab.tsx | 479 ++++++++---------- .../packages/app/src/design-polish.css | 50 -- 3 files changed, 211 insertions(+), 322 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 30b5610f..d37fb3d7 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -978,7 +978,7 @@ "packages/app/public/amico.svg": "a14b9d543d895bcdf0758f7b9ef5908ee0acaac794446494af059b159247db8f", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", "packages/app/src/app.tsx": "91ea1817db2f7e21caae642f3c2e276b4835fcecf499699cb787e7a5521ae20b", - "packages/app/src/design-polish.css": "bd13b37fcd73077ce9b527aa2207967430715056d133d6fb888bf9b7c97d53b1", + "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", @@ -1444,7 +1444,7 @@ "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "b19e09eea767261fd412518d7f8eb711e6c15edbae1953081f96eff7dcd951b6", + "packages/app/src/components/session/session-preview-tab.tsx": "8c17771e40a0385b394ea0ea1a6034f475b6f9ca1a348604b6ef507595017d9c", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", 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 index 9512453c..4a803aef 100644 --- 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 @@ -6,6 +6,11 @@ import { createEffect, createSignal, For, on, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { useSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { Modifier, type DragEndEvent, type DragMoveEvent, type DragOperation, type DragStartEvent } from "@dnd-kit/abstract" +import type { DragDropManager } from "@dnd-kit/dom" import { Icon } from "@opencode-ai/ui/icon" import { Tabs } from "@opencode-ai/ui/tabs" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" @@ -32,30 +37,21 @@ import { const MAX_PREVIEW_TABS = 8 const DROP_EDGE_PX = 32 +const RAIL_CAPTURE_PX = 24 type PreviewDrop = { leafID: string position: PreviewDropPosition targetIndex?: number - sourceOffset?: number kind: "rail" | "pane" } type PreviewDrag = { path: string sourceLeafID: string - width: number - x: number - y: number drop?: PreviewDrop } -type PreviewRailReorder = { - direction: "source" | "source-remote" | "left" | "right" - width: number - offset?: number -} - type PreviewLeafEdges = { top: boolean right: boolean @@ -63,6 +59,36 @@ type PreviewLeafEdges = { left: boolean } +type PreviewSortableSource = { + id: string | number + sortable: { initialGroup?: string | number; group?: string | number; index: number } +} + +class PreviewRailSnapModifier extends Modifier Iterable }> { + apply(operation: DragOperation) { + const source = operation.shape?.initial.boundingRectangle + if (!source) return operation.transform + + const sourceCenterY = source.top + source.height / 2 + operation.transform.y + let closestRail: DOMRect | undefined + let closestDistance = Infinity + for (const rail of this.options?.rails() ?? []) { + const rect = rail.getBoundingClientRect() + const distance = Math.abs(sourceCenterY - (rect.top + rect.height / 2)) + if (distance < closestDistance) { + closestRail = rect + closestDistance = distance + } + } + if (!closestRail || closestDistance > closestRail.height / 2 + RAIL_CAPTURE_PX) return operation.transform + + return { + ...operation.transform, + y: closestRail.top + (closestRail.height - source.height) / 2 - source.top, + } + } +} + export function SessionPreviewTab(props: { previewFile: Accessor }) { const platform = usePlatform() const [dirtyPaths, setDirtyPaths] = createStore>({}) @@ -71,14 +97,16 @@ export function SessionPreviewTab(props: { previewFile: Accessor const [capacityMessage, setCapacityMessage] = createSignal(null) const [closingPath, setClosingPath] = createSignal(null) const [previewDrag, setPreviewDrag] = createSignal(null) - const [dragProxy, setDragProxy] = createSignal<{ path: string; x: number; y: number; leaving: boolean } | null>(null) const [resizingSplitID, setResizingSplitID] = createSignal(null) const [tabContextMenu, setTabContextMenu] = createSignal<{ path: string; x: number; y: number } | null>(null) const leafElements = new Map() const [hostMounts, setHostMounts] = createStore>({}) let stopPreviewDrag: (() => void) | undefined - let dragProxyTimer: ReturnType | undefined + let previewDragDrop: PreviewDrop | undefined + let previewDragFocus: + | { target: HTMLElement; scroller?: HTMLElement; scrollTop?: number } + | undefined const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs) @@ -123,7 +151,6 @@ export function SessionPreviewTab(props: { previewFile: Accessor }) onCleanup(() => { stopPreviewDrag?.() - if (dragProxyTimer) clearTimeout(dragProxyTimer) }) const zoomForPath = (path: string) => previewLeafContaining(workspace().tree, path)?.zoom ?? 100 @@ -163,8 +190,6 @@ export function SessionPreviewTab(props: { previewFile: Accessor clientY: number, path: string, sourceLeafID: string, - sourceStartX: number, - sourceGrabOffset: number, ): PreviewDrop | undefined => { for (const leaf of previewLeaves(workspace().tree)) { const element = leafElements.get(leaf.id) @@ -197,12 +222,10 @@ export function SessionPreviewTab(props: { previewFile: Accessor return clientX < midpoint }) const insertionIndex = targetIndex === -1 ? insertionTabs.length : targetIndex - const sourceOffset = sourceLeaf?.id === leaf.id ? clientX - sourceGrabOffset - sourceStartX : undefined return { leafID: leaf.id, position: "center", targetIndex: insertionIndex, - sourceOffset, kind: "rail", } } @@ -236,143 +259,121 @@ export function SessionPreviewTab(props: { previewFile: Accessor return undefined } - const startPreviewDrag = (event: PointerEvent, path: string, sourceLeafID: string) => { - event.stopPropagation() - if ( - event.button !== 0 || - (event.target instanceof Element && event.target.closest('[data-slot="tabs-trigger-close-button"]')) - ) - return + const isSelected = (path: string) => previewLeafContaining(workspace().tree, path)?.selectedPath === path + const rememberPreviewFocus = (path: string) => { const activeElement = document.activeElement - const focusTarget = - activeElement instanceof HTMLElement && - activeElement.closest("[data-preview-host]")?.getAttribute("data-preview-host") === path - ? activeElement - : undefined - const focusScroller = focusTarget?.closest(".cm-scroller") - const focusScrollTop = focusScroller?.scrollTop - const origin = { x: event.clientX, y: event.clientY } - let active = false - const source = event.currentTarget as HTMLElement - const sourceRect = - source.closest("[data-preview-tab]")?.getBoundingClientRect() ?? source.getBoundingClientRect() - const sourceWidth = sourceRect.width - const sourceGrabOffset = event.clientX - sourceRect.left - source.setPointerCapture?.(event.pointerId) - const setDocumentDrag = (dragging: boolean) => - document.documentElement.toggleAttribute("data-preview-tab-dragging", dragging) - - const clearDragProxy = (fade: boolean) => { - const proxy = dragProxy() - if (!proxy) return - if (!fade) { - if (dragProxyTimer) clearTimeout(dragProxyTimer) - dragProxyTimer = undefined - setDragProxy(null) - return - } - setDragProxy({ ...proxy, leaving: true }) - if (dragProxyTimer) clearTimeout(dragProxyTimer) - dragProxyTimer = setTimeout(() => { - setDragProxy(null) - dragProxyTimer = undefined - }, 160) - } - const stop = (fadeProxy = false) => { - window.removeEventListener("pointermove", onMove) - window.removeEventListener("pointerup", onUp) - window.removeEventListener("pointercancel", onCancel) - if (source.hasPointerCapture?.(event.pointerId)) source.releasePointerCapture(event.pointerId) - stopPreviewDrag = undefined - setPreviewDrag(null) - setDocumentDrag(false) - clearDragProxy(fadeProxy) - } - const onMove = (moveEvent: PointerEvent) => { - if (!active) { - if (Math.hypot(moveEvent.clientX - origin.x, moveEvent.clientY - origin.y) < 4) return - active = true - setDocumentDrag(true) - } - moveEvent.preventDefault() - if (dragProxyTimer) clearTimeout(dragProxyTimer) - dragProxyTimer = undefined - const next = { - path, - sourceLeafID, - width: sourceWidth, - x: moveEvent.clientX, - y: moveEvent.clientY, - drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID, sourceRect.left, sourceGrabOffset), - } - setPreviewDrag(next) - setDragProxy(next.drop?.kind === "rail" ? null : { path, x: next.x, y: next.y, leaving: false }) - } - const onUp = (upEvent: PointerEvent) => { - const drop = active - ? dropTargetAt(upEvent.clientX, upEvent.clientY, path, sourceLeafID, sourceRect.left, sourceGrabOffset) - : undefined - stop(!drop) - if (!drop) return - setWorkspace((current) => - movePreviewTab(current, { - path, - targetLeafID: drop.leafID, - position: drop.position, - targetIndex: drop.targetIndex, - }), - ) - requestAnimationFrame(() => { - if (focusTarget?.isConnected) focusTarget.focus({ preventScroll: true }) - requestAnimationFrame(() => { - if (focusScroller?.isConnected && focusScrollTop !== undefined) focusScroller.scrollTop = focusScrollTop - }) - }) + if (activeElement instanceof HTMLElement && activeElement.closest("[data-preview-host]")?.getAttribute("data-preview-host") === path) { + const scroller = activeElement.closest(".cm-scroller") + previewDragFocus = { target: activeElement, scroller: scroller ?? undefined, scrollTop: scroller?.scrollTop } + return } - const onCancel = () => stop(true) + previewDragFocus = undefined + } - stopPreviewDrag?.() - stopPreviewDrag = stop - window.addEventListener("pointermove", onMove) - window.addEventListener("pointerup", onUp) - window.addEventListener("pointercancel", onCancel) + const sortableSource = (source: unknown): PreviewSortableSource | undefined => { + if (!source || typeof source !== "object" || !("sortable" in source)) return undefined + return source as PreviewSortableSource } - const isSelected = (path: string) => previewLeafContaining(workspace().tree, path)?.selectedPath === path + const handlePreviewDragStart = (event: DragStartEvent) => { + const source = sortableSource(event.operation.source) + if (!source) return + document.documentElement.toggleAttribute("data-preview-tab-dragging", true) + } - const railReorder = (leaf: PreviewLeaf, path: string): PreviewRailReorder | undefined => { - const drag = previewDrag() - const drop = drag?.drop - if (!drag || !drop || drop.kind !== "rail") return undefined - - const source = previewLeaves(workspace().tree).find((candidate) => candidate.id === drag.sourceLeafID) - if (!source) return undefined - const sourceIndex = source.tabs.indexOf(drag.path) - if (sourceIndex === -1) return undefined - - if (leaf.id === source.id && path === drag.path) { - const direction: PreviewRailReorder["direction"] = drop.leafID === source.id ? "source" : "source-remote" - return { - direction, - width: drag.width, - offset: drop.sourceOffset, - } - } + const handlePreviewDragMove = (event: DragMoveEvent) => { + const source = sortableSource(event.operation.source) + if (!source || !(event.nativeEvent instanceof PointerEvent)) return + const sourceLeafID = source.sortable.initialGroup?.toString() + if (!sourceLeafID) return + const drop = dropTargetAt(event.nativeEvent.clientX, event.nativeEvent.clientY, source.id.toString(), sourceLeafID) + previewDragDrop = drop + setPreviewDrag(drop?.kind === "pane" ? { path: source.id.toString(), sourceLeafID, drop } : null) + } - const index = leaf.tabs.indexOf(path) - if (index === -1) return undefined + const restorePreviewDragFocus = () => { + const focus = previewDragFocus + previewDragFocus = undefined + requestAnimationFrame(() => { + if (focus?.target.isConnected) focus.target.focus({ preventScroll: true }) + requestAnimationFrame(() => { + if (focus?.scroller?.isConnected && focus.scrollTop !== undefined) focus.scroller.scrollTop = focus.scrollTop + }) + }) + } - if (source.id === drop.leafID && leaf.id === source.id) { - const targetIndex = Math.max(0, Math.min(drop.targetIndex ?? source.tabs.length - 1, source.tabs.length - 1)) - if (sourceIndex > targetIndex && index >= targetIndex && index < sourceIndex) - return { direction: "right" as const, width: drag.width } - if (sourceIndex < targetIndex && index > sourceIndex && index <= targetIndex) - return { direction: "left" as const, width: drag.width } - return undefined - } + const handlePreviewDragEnd = (event: DragEndEvent) => { + const source = sortableSource(event.operation.source) + const sourceLeafID = source?.sortable.initialGroup?.toString() + const drop = + source && + sourceLeafID && + event.nativeEvent instanceof PointerEvent + ? dropTargetAt(event.nativeEvent.clientX, event.nativeEvent.clientY, source.id.toString(), sourceLeafID) + : previewDragDrop + previewDragDrop = undefined + setPreviewDrag(null) + document.documentElement.toggleAttribute("data-preview-tab-dragging", false) + if (!source || !drop || event.canceled) return + + const path = source.id.toString() + const targetLeafID = drop.leafID + if (!sourceLeafID || !targetLeafID) return + setWorkspace((current) => + movePreviewTab(current, { + path, + targetLeafID, + position: drop.position, + targetIndex: drop.targetIndex, + }), + ) + restorePreviewDragFocus() + } - return undefined + const PreviewSortableTab = (tab: { path: string }) => { + const leaf = () => previewLeafContaining(workspace().tree, tab.path) + const sortable = useSortable({ + get id() { + return tab.path + }, + get index() { + return leaf()?.tabs.indexOf(tab.path) ?? -1 + }, + get group() { + return leaf()?.id + }, + }) + return ( +
+ rememberPreviewFocus(tab.path)} + onContextMenu={(event: MouseEvent) => openTabContextMenu(event, tab.path)} + closeButton={ + + } + hideCloseButton + onMiddleClick={() => closePath(tab.path)} + > + + +
+ ) } const startDividerResize = (event: PointerEvent, split: PreviewSplit) => { @@ -452,24 +453,6 @@ export function SessionPreviewTab(props: { previewFile: Accessor const drop = previewDrag()?.drop return drop?.kind === "pane" ? drop : undefined } - const railGhost = () => { - const drag = previewDrag() - const drop = drag?.drop - if (!drag || !drop || drop.kind !== "rail" || drop.leafID !== leaf.id || drag.sourceLeafID === leaf.id) - return undefined - return { path: drag.path, index: Math.max(0, Math.min(drop.targetIndex ?? leaf.tabs.length, leaf.tabs.length)) } - } - const RailGhost = () => { - const ghost = railGhost() - if (!ghost) return null - return ( - - ) - } return (
leafElements.set(leaf.id, element)} @@ -489,60 +472,8 @@ export function SessionPreviewTab(props: { previewFile: Accessor > - {(path, index) => { - const reorder = () => railReorder(leaf, path) - return ( - <> - - - -
- startPreviewDrag(event, path, leaf.id)} - onContextMenu={(event: MouseEvent) => openTabContextMenu(event, path)} - closeButton={ - - } - hideCloseButton - onMiddleClick={() => closePath(path)} - > - - -
- - ) - }} + {(path) => }
- - -
@@ -563,24 +494,6 @@ export function SessionPreviewTab(props: { previewFile: Accessor return ( <>
- - {(proxy) => ( - - - - )} - } > -
- {(pane) => renderPane(pane)} - - {(path) => ( - - {(mount) => ( - container.classList.add("preview-pane-renderer-mount")} - > - + + + )} + + )} + +
+
diff --git a/packages/app-bundle/overlay/packages/app/src/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index b14050db..476bdcc9 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -116,40 +116,11 @@ height: var(--space-6); } -#review-panel [data-preview-tab] { - transition: - transform 0.16s ease, - opacity 0.16s ease; -} - -#review-panel [data-preview-tab][data-preview-reorder="source"] { - transform: translateX(var(--preview-drag-offset)); - transition: none; -} - -#review-panel [data-preview-tab][data-preview-reorder="source-remote"] { - opacity: 0; -} - -#review-panel [data-preview-tab][data-preview-reorder="right"] { - transform: translateX(var(--preview-drag-width)); -} - -#review-panel [data-preview-tab][data-preview-reorder="left"] { - transform: translateX(calc(-1 * var(--preview-drag-width))); -} - html[data-preview-tab-dragging], html[data-preview-tab-dragging] * { cursor: default !important; } -@media (prefers-reduced-motion: reduce) { - #review-panel [data-preview-tab] { - transition: none; - } -} - body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] @@ -360,27 +331,6 @@ body[data-new-layout] #review-panel .preview-pane-drop-preview[data-preview-drop-preview="bottom"] { bottom: 0; } -[data-preview-tab-drag-proxy] { - position: fixed; - z-index: 60; - top: 0; - left: 0; - display: flex; - align-items: center; - max-width: min-content; - padding: var(--space-1) var(--space-2); - border: var(--border-width) solid var(--v2-border-border-base); - border-radius: var(--radius-md); - background: var(--v2-background-bg-layer-01); - box-shadow: var(--elev-float); - opacity: 0.92; - pointer-events: none; - transform: translate3d(calc(var(--preview-drag-x) + var(--space-2)), calc(var(--preview-drag-y) + var(--space-2)), 0); - transition: opacity 0.16s ease; -} -[data-preview-tab-drag-proxy][data-leaving] { - opacity: 0; -} #review-panel .preview-pane-drop-preview[data-preview-drop-preview="center"] { inset: 0; } From df5ca068119c58fc68fa8455cd918b1b9f311f77 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 20:49:16 -0400 Subject: [PATCH 6/8] fix(app-bundle): restore packageable overlay --- packages/app-bundle/manifest.json | 12 +- .../session/session-preview-tree.ts | 223 ++++++++++++++++++ .../app/src/context/layout-side-panel-tabs.ts | 46 ++++ .../src/utils/vscode-explorer-icon-theme.ts | 156 ++++++++++++ packages/app-bundle/scripts/overlay-sync.mjs | 20 +- .../extension/src/amicode_service_runner.ts | 19 +- .../extension/test/yellow_chunks_853.test.ts | 96 +------- 7 files changed, 467 insertions(+), 105 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/vscode-explorer-icon-theme.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index d37fb3d7..dace6c6b 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -6,10 +6,10 @@ "fork_sha": "d161eb0cfc6d03a53311e083b590005b4161c13a", "upstream_base": "v1.18.29", "upstream_base_sha": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", - "extracted_at": "2026-09-10T22:01:06.041Z", + "extracted_at": "2026-09-11T00:45:35.788Z", "per_package": { "packages/app": { - "A": 148, + "A": 151, "M": 153, "D": 0 }, @@ -40,7 +40,7 @@ } }, "counts": { - "overlay_total": 620, + "overlay_total": 622, "deletions": 7, "server_coupled": 47 }, @@ -461,6 +461,7 @@ "packages/app/src/context/command.tsx": "M", "packages/app/src/context/directory-sync.ts": "M", "packages/app/src/context/language.tsx": "M", + "packages/app/src/context/layout-side-panel-tabs.ts": "A", "packages/app/src/context/layout-tabs.test.ts": "M", "packages/app/src/context/layout-tabs.ts": "M", "packages/app/src/context/layout.tsx": "M", @@ -538,6 +539,7 @@ "packages/app/src/utils/session-list-conformance.ts": "A", "packages/app/src/utils/session-list-state.ts": "A", "packages/app/src/utils/start-prompt.ts": "A", + "packages/app/src/utils/vscode-explorer-icon-theme.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", @@ -823,6 +825,7 @@ "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/session-preview-tree.ts": "A", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "M", "packages/app/src/components/session/use-context-warning.ts": "A", "packages/app/src/components/settings-v2/data-storage-controller.ts": "A", @@ -1083,6 +1086,7 @@ "packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172", "packages/app/src/context/directory-sync.ts": "a3921044097f3fbcfb20eaec500f24344f12a43e137417c86f15d39dcc6e4b2b", "packages/app/src/context/language.tsx": "cb545e04c128bf981b2b72ac407f9220cf9e80337fe8c87482ffe85407e0cf9a", + "packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76", "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c", "packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5", "packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb", @@ -1160,6 +1164,7 @@ "packages/app/src/utils/session-list-conformance.ts": "39af003bae6159761ed5b0ab709fc1ab796194f8c0543dc80f29f3d49f1af4c3", "packages/app/src/utils/session-list-state.ts": "3c6ac47f3e73b14e6d5380c5d8500d0df330740e4652e6afac0547c86c9b9ea7", "packages/app/src/utils/start-prompt.ts": "809d8ad0ab6de76a97503f8368ade25f771431be3f92eb8f039a2c65244ab20f", + "packages/app/src/utils/vscode-explorer-icon-theme.ts": "0d700027004f4a08265682e19364bd931313d0658b3d75d7874397b407ade2ab", "packages/app/src/utils/web-zoom.test.ts": "f373618138add504faa44dd6d2c9e3489b6e8dd0093ac6bb4f00f55307864e3d", "packages/app/src/utils/web-zoom.ts": "d5119a07e9fc61880b7294fadd23801933d99a9d3dbd0fadcafed1f786bd7d7a", "packages/app/src/utils/webview-context-menu.ts": "876b532fc9c00156f722d6424c28476f34b2db1bd0bc3cff3c9866152600e7c3", @@ -1445,6 +1450,7 @@ "packages/app/src/components/session/session-header.tsx": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", "packages/app/src/components/session/session-preview-tab.tsx": "8c17771e40a0385b394ea0ea1a6034f475b6f9ca1a348604b6ef507595017d9c", + "packages/app/src/components/session/session-preview-tree.ts": "13930fcd8a8ae9df4c37ba9ea306e162fd08f0e429249ddea21d937675d3ad7e", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts new file mode 100644 index 00000000..d4bbee9b --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts @@ -0,0 +1,223 @@ +export type PreviewLeaf = { + kind: "leaf" + id: string + tabs: string[] + selectedPath: string | null + zoom: number +} + +export type PreviewSplit = { + kind: "split" + id: string + direction: "horizontal" | "vertical" + ratio: number + first: PreviewPane + second: PreviewPane +} + +export type PreviewPane = PreviewLeaf | PreviewSplit + +export type PreviewWorkspace = { + tree: PreviewPane + focusedLeafID: string + nextPaneID: number +} + +export type PreviewDropPosition = "center" | "left" | "right" | "top" | "bottom" + +export const PREVIEW_LEAF_MIN_SIZE = 150 + +export const createPreviewWorkspace = (paths: readonly string[] = []): PreviewWorkspace => ({ + tree: { + kind: "leaf", + id: "root", + tabs: [...paths], + selectedPath: paths.at(-1) ?? null, + zoom: 100, + }, + focusedLeafID: "root", + nextPaneID: 1, +}) + +export const previewLeaves = (tree: PreviewPane): PreviewLeaf[] => { + if (tree.kind === "leaf") return [tree] + return [...previewLeaves(tree.first), ...previewLeaves(tree.second)] +} + +export const previewTabCount = (tree: PreviewPane): number => previewLeaves(tree).reduce((count, leaf) => count + leaf.tabs.length, 0) + +export const previewLeafByID = (tree: PreviewPane, leafID: string): PreviewLeaf | undefined => + previewLeaves(tree).find((leaf) => leaf.id === leafID) + +export const previewLeafContaining = (tree: PreviewPane, path: string): PreviewLeaf | undefined => + previewLeaves(tree).find((leaf) => leaf.tabs.includes(path)) + +export const previewMinimumExtent = (tree: PreviewPane, axis: "horizontal" | "vertical"): number => { + if (tree.kind === "leaf") return PREVIEW_LEAF_MIN_SIZE + const first = previewMinimumExtent(tree.first, axis) + const second = previewMinimumExtent(tree.second, axis) + return tree.direction === axis ? first + second : Math.max(first, second) +} + +const mapLeaf = (tree: PreviewPane, leafID: string, map: (leaf: PreviewLeaf) => PreviewPane): PreviewPane => { + if (tree.kind === "leaf") return tree.id === leafID ? map(tree) : tree + return { + ...tree, + first: mapLeaf(tree.first, leafID, map), + second: mapLeaf(tree.second, leafID, map), + } +} + +const mapSplit = (tree: PreviewPane, splitID: string, map: (split: PreviewSplit) => PreviewSplit): PreviewPane => { + if (tree.kind === "leaf") return tree + return { + ...tree, + ...(tree.id === splitID ? map(tree) : {}), + first: mapSplit(tree.first, splitID, map), + second: mapSplit(tree.second, splitID, map), + } +} + +export const resizePreviewSplit = (workspace: PreviewWorkspace, splitID: string, ratio: number): PreviewWorkspace => ({ + ...workspace, + tree: mapSplit(workspace.tree, splitID, (split) => ({ ...split, ratio: Math.min(Math.max(ratio, 0), 1) })), +}) + +const leafWithRemovedPath = (leaf: PreviewLeaf, path: string): PreviewLeaf => { + const index = leaf.tabs.indexOf(path) + if (index === -1) return leaf + const tabs = leaf.tabs.filter((tab) => tab !== path) + return { + ...leaf, + tabs, + selectedPath: leaf.selectedPath === path ? tabs[index - 1] ?? tabs[index] ?? null : leaf.selectedPath, + } +} + +const collapseEmptyLeaves = (tree: PreviewPane): PreviewPane => { + if (tree.kind === "leaf") return tree + const first = collapseEmptyLeaves(tree.first) + const second = collapseEmptyLeaves(tree.second) + if (first.kind === "leaf" && first.tabs.length === 0) return second + if (second.kind === "leaf" && second.tabs.length === 0) return first + return { ...tree, first, second } +} + +const reorderTabs = (tabs: readonly string[], path: string, toIndex: number): string[] => { + const next = [...tabs] + const fromIndex = next.indexOf(path) + if (fromIndex === -1) return next + const target = Math.max(0, Math.min(toIndex, next.length - 1)) + if (fromIndex === target) return next + next.splice(target, 0, next.splice(fromIndex, 1)[0]) + return next +} + +export const openPreviewPath = (workspace: PreviewWorkspace, path: string): PreviewWorkspace => { + const existing = previewLeafContaining(workspace.tree, path) + if (existing) + return { + ...workspace, + focusedLeafID: existing.id, + tree: mapLeaf(workspace.tree, existing.id, (leaf) => ({ ...leaf, selectedPath: path })), + } + + const focused = previewLeafByID(workspace.tree, workspace.focusedLeafID) ?? previewLeaves(workspace.tree)[0] + if (!focused) return workspace + return { + ...workspace, + focusedLeafID: focused.id, + tree: mapLeaf(workspace.tree, focused.id, (leaf) => ({ ...leaf, tabs: [...leaf.tabs, path], selectedPath: path })), + } +} + +export const selectPreviewPath = (workspace: PreviewWorkspace, leafID: string, path: string): PreviewWorkspace => { + const leaf = previewLeafByID(workspace.tree, leafID) + if (!leaf || !leaf.tabs.includes(path)) return workspace + return { + ...workspace, + focusedLeafID: leafID, + tree: mapLeaf(workspace.tree, leafID, (current) => ({ ...current, selectedPath: path })), + } +} + +export const setPreviewLeafZoom = (workspace: PreviewWorkspace, leafID: string, zoom: number, maximum = 500): PreviewWorkspace => ({ + ...workspace, + tree: mapLeaf(workspace.tree, leafID, (leaf) => ({ ...leaf, zoom: Math.round(Math.min(Math.max(zoom, 50), Math.min(Math.max(maximum, 50), 1000))) })), +}) + +export const removePreviewPath = (workspace: PreviewWorkspace, path: string): PreviewWorkspace => { + const source = previewLeafContaining(workspace.tree, path) + if (!source) return workspace + const tree = collapseEmptyLeaves(mapLeaf(workspace.tree, source.id, (leaf) => leafWithRemovedPath(leaf, path))) + const focusedLeaf = previewLeafByID(tree, workspace.focusedLeafID) ?? previewLeaves(tree)[0] + return { ...workspace, tree, focusedLeafID: focusedLeaf?.id ?? "root" } +} + +export const movePreviewTab = ( + workspace: PreviewWorkspace, + input: { path: string; targetLeafID: string; position: PreviewDropPosition; targetIndex?: number }, +): PreviewWorkspace => { + const source = previewLeafContaining(workspace.tree, input.path) + const target = previewLeafByID(workspace.tree, input.targetLeafID) + if (!source || !target) return workspace + + if (input.position === "center") { + if (source.id === target.id) + return { + ...workspace, + focusedLeafID: target.id, + tree: mapLeaf(workspace.tree, target.id, (leaf) => ({ + ...leaf, + tabs: input.targetIndex === undefined ? leaf.tabs : reorderTabs(leaf.tabs, input.path, input.targetIndex), + selectedPath: input.path, + })), + } + + const withoutSource = collapseEmptyLeaves(mapLeaf(workspace.tree, source.id, (leaf) => leafWithRemovedPath(leaf, input.path))) + return { + ...workspace, + focusedLeafID: target.id, + tree: mapLeaf(withoutSource, target.id, (leaf) => ({ + ...leaf, + tabs: [ + ...leaf.tabs.slice(0, Math.max(0, Math.min(input.targetIndex ?? leaf.tabs.length, leaf.tabs.length))), + input.path, + ...leaf.tabs.slice(Math.max(0, Math.min(input.targetIndex ?? leaf.tabs.length, leaf.tabs.length))), + ], + selectedPath: input.path, + })), + } + } + + // A one-tab leaf cannot split itself: that would manufacture an empty sibling. + if (source.id === target.id && source.tabs.length === 1) return workspace + + const withoutSource = collapseEmptyLeaves(mapLeaf(workspace.tree, source.id, (leaf) => leafWithRemovedPath(leaf, input.path))) + const targetAfterRemoval = previewLeafByID(withoutSource, input.targetLeafID) + if (!targetAfterRemoval) return workspace + + const newLeaf: PreviewLeaf = { + kind: "leaf", + id: `pane-${workspace.nextPaneID}`, + tabs: [input.path], + selectedPath: input.path, + zoom: source.zoom, + } + const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical" + const newPaneBeforeTarget = input.position === "left" || input.position === "top" + const split: PreviewSplit = { + kind: "split", + id: `split-${workspace.nextPaneID}`, + direction, + ratio: 0.5, + first: newPaneBeforeTarget ? newLeaf : targetAfterRemoval, + second: newPaneBeforeTarget ? targetAfterRemoval : newLeaf, + } + + return { + tree: mapLeaf(withoutSource, targetAfterRemoval.id, () => split), + focusedLeafID: newLeaf.id, + nextPaneID: workspace.nextPaneID + 1, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts b/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts new file mode 100644 index 00000000..6f8b0a0b --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/layout-side-panel-tabs.ts @@ -0,0 +1,46 @@ +import { SESSION_PREVIEW_TAB } from "./layout-tabs" + +export const SIDE_PANEL_TAB_IDS = ["home", "review", "context", "pulseInspector", SESSION_PREVIEW_TAB] as const + +export type SidePanelTabID = (typeof SIDE_PANEL_TAB_IDS)[number] + +export const DEFAULT_SIDE_PANEL_TAB_ORDER: SidePanelTabID[] = [...SIDE_PANEL_TAB_IDS] + +const isSidePanelTabID = (value: unknown): value is SidePanelTabID => + typeof value === "string" && SIDE_PANEL_TAB_IDS.includes(value as SidePanelTabID) + +export const normalizeSidePanelTabOrder = (value: unknown): SidePanelTabID[] => { + const seen = new Set(["home"]) + const order: SidePanelTabID[] = ["home"] + const persisted = Array.isArray(value) + ? value.flatMap((tab) => { + if (!isSidePanelTabID(tab) || seen.has(tab)) return [] + seen.add(tab) + return [tab] + }) + : [] + + order.push(...persisted) + + for (const tab of SIDE_PANEL_TAB_IDS) { + if (!seen.has(tab)) order.push(tab) + } + + return order +} + +export const reorderSidePanelTabs = ( + order: readonly SidePanelTabID[], + tab: SidePanelTabID, + toIndex: number, +): SidePanelTabID[] => { + const next = normalizeSidePanelTabOrder(order) + const fromIndex = next.indexOf(tab) + if (fromIndex <= 0) return next + + const target = Math.max(1, Math.min(toIndex, next.length - 1)) + if (fromIndex === target) return next + + next.splice(target, 0, next.splice(fromIndex, 1)[0]) + return next +} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/vscode-explorer-icon-theme.ts b/packages/app-bundle/overlay/packages/app/src/utils/vscode-explorer-icon-theme.ts new file mode 100644 index 00000000..fb99e8a5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/vscode-explorer-icon-theme.ts @@ -0,0 +1,156 @@ +import { createSignal } from "solid-js" + +type AssetMime = "image/svg+xml" | "font/woff" | "font/woff2" | "font/ttf" | "font/otf" +type FontFormat = "woff" | "woff2" | "truetype" | "opentype" + +export type ExplorerFileIcon = + | { kind: "font"; glyph: string; color?: string } + | { kind: "svg"; asset: string } + +export interface ExplorerIconTheme { + mode: "font" | "svg" | "none" + assets: Record + fileExtensions: Record + fileNames: Record + defaultFile?: ExplorerFileIcon + font?: { asset: string; format: FontFormat; size: string } +} + +const FONT_FAMILY = "amicode-vscode-explorer-icon" +const ASSET_ID = /^asset-\d+$/ +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/ +const COLOR = /^(?:#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([\d.%\s,]+\)|currentColor|inherit|transparent)$/i +const FONT_SIZE = /^(?:0|[1-9]\d*)(?:\.\d+)?(?:%|px|em|rem)$/ +const FONT_MIMES = new Set(["font/woff", "font/woff2", "font/ttf", "font/otf"]) +const FONT_FORMATS = new Set(["woff", "woff2", "truetype", "opentype"]) +const MAX_ASSET_BYTES = 1_500_000 +const MAX_THEME_BYTES = 12_000_000 +const MAX_MAP_ENTRIES = 10_000 + +const emptyTheme = (): ExplorerIconTheme => ({ mode: "none", assets: {}, fileExtensions: {}, fileNames: {} }) +const [theme, setTheme] = createSignal(emptyTheme()) +let fontFaceStyle: HTMLStyleElement | undefined + +export const explorerIconTheme = theme +export const explorerIconFontFamily = FONT_FAMILY + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function parseAssets(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined + const entries = Object.entries(value) + if (entries.length > MAX_MAP_ENTRIES) return undefined + let total = 0 + const assets: Record = {} + for (const [id, asset] of entries) { + if (!ASSET_ID.test(id) || !isRecord(asset) || typeof asset.mime !== "string" || typeof asset.data !== "string") return undefined + if (!(["image/svg+xml", "font/woff", "font/woff2", "font/ttf", "font/otf"] as string[]).includes(asset.mime)) return undefined + if (asset.data.length > MAX_ASSET_BYTES || !BASE64.test(asset.data)) return undefined + total += asset.data.length + if (total > MAX_THEME_BYTES) return undefined + assets[id] = { mime: asset.mime as AssetMime, data: asset.data } + } + return assets +} + +function parseIcon(value: unknown, mode: "font" | "svg", assets: Record): ExplorerFileIcon | undefined { + if (!isRecord(value) || value.kind !== mode) return undefined + if (mode === "svg") { + if (typeof value.asset !== "string" || !ASSET_ID.test(value.asset) || assets[value.asset]?.mime !== "image/svg+xml") return undefined + return { kind: "svg", asset: value.asset } + } + if (typeof value.glyph !== "string" || value.glyph.length === 0 || value.glyph.length > 32) return undefined + if (value.color !== undefined && (typeof value.color !== "string" || !COLOR.test(value.color))) return undefined + return { kind: "font", glyph: value.glyph, ...(typeof value.color === "string" ? { color: value.color } : {}) } +} + +function parseIconMap( + value: unknown, + mode: "font" | "svg", + assets: Record, +): Record | undefined { + if (!isRecord(value)) return undefined + const entries = Object.entries(value) + if (entries.length > MAX_MAP_ENTRIES) return undefined + const icons: Record = {} + for (const [name, icon] of entries) { + if (name.length === 0 || name.length > 255) return undefined + const parsed = parseIcon(icon, mode, assets) + if (!parsed) return undefined + icons[name] = parsed + } + return icons +} + +function parseTheme(value: unknown): ExplorerIconTheme | undefined { + if (!isRecord(value) || (value.mode !== "font" && value.mode !== "svg" && value.mode !== "none")) return undefined + if (value.mode === "none") return emptyTheme() + const assets = parseAssets(value.assets) + if (!assets) return undefined + const fileExtensions = parseIconMap(value.fileExtensions, value.mode, assets) + const fileNames = parseIconMap(value.fileNames, value.mode, assets) + const defaultFile = value.defaultFile === undefined ? undefined : parseIcon(value.defaultFile, value.mode, assets) + if (!fileExtensions || !fileNames || (value.defaultFile !== undefined && !defaultFile)) return undefined + if (value.mode === "svg") return { mode: "svg", assets, fileExtensions, fileNames, ...(defaultFile ? { defaultFile } : {}) } + if (!isRecord(value.font) || typeof value.font.asset !== "string" || typeof value.font.format !== "string" || typeof value.font.size !== "string") return undefined + if (!ASSET_ID.test(value.font.asset) || !FONT_MIMES.has(assets[value.font.asset]?.mime) || !FONT_FORMATS.has(value.font.format as FontFormat) || !FONT_SIZE.test(value.font.size)) return undefined + return { + mode: "font", + assets, + fileExtensions, + fileNames, + ...(defaultFile ? { defaultFile } : {}), + font: { asset: value.font.asset, format: value.font.format as FontFormat, size: value.font.size }, + } +} + +function syncFontFace(next: ExplorerIconTheme): void { + fontFaceStyle?.remove() + fontFaceStyle = undefined + if (next.mode !== "font" || !next.font || typeof document === "undefined") return + const source = explorerIconAssetUrlFrom(next, next.font.asset) + if (!source) return + fontFaceStyle = document.createElement("style") + fontFaceStyle.dataset.amicodeExplorerIconTheme = "true" + fontFaceStyle.textContent = `@font-face{font-family:"${FONT_FAMILY}";src:url("${source}") format("${next.font.format}");font-weight:normal;font-style:normal;}` + document.head.appendChild(fontFaceStyle) +} + +/** Accepts only the extension's bounded, opaque icon-theme payload. */ +export function adoptExplorerIconTheme(value: unknown): boolean { + const next = parseTheme(value) + if (!next) return false + setTheme(next) + syncFontFace(next) + return true +} + +export function resetExplorerIconTheme(): void { + setTheme(emptyTheme()) + syncFontFace(emptyTheme()) +} + +function explorerIconAssetUrlFrom(source: ExplorerIconTheme, asset: string): string | undefined { + const entry = source.assets[asset] + return entry ? `data:${entry.mime};base64,${entry.data}` : undefined +} + +/** Resolves a theme asset by opaque ID. This deliberately has no path argument. */ +export function explorerIconAssetUrl(asset: string): string | undefined { + return explorerIconAssetUrlFrom(theme(), asset) +} + +/** Mirrors VS Code's file-name-first, longest-extension resolution order. */ +export function resolveExplorerFileIcon(filePath: string): ExplorerFileIcon | undefined { + const fileName = filePath.split(/[\\/]/).pop() ?? filePath + const current = theme() + const named = current.fileNames[fileName] ?? current.fileNames[fileName.toLowerCase()] + if (named) return named + for (let dot = fileName.indexOf("."); dot >= 0; dot = fileName.indexOf(".", dot + 1)) { + const extension = fileName.slice(dot + 1).toLowerCase() + if (current.fileExtensions[extension]) return current.fileExtensions[extension] + } + return current.defaultFile +} diff --git a/packages/app-bundle/scripts/overlay-sync.mjs b/packages/app-bundle/scripts/overlay-sync.mjs index fbfbac29..6e3e1094 100644 --- a/packages/app-bundle/scripts/overlay-sync.mjs +++ b/packages/app-bundle/scripts/overlay-sync.mjs @@ -8,6 +8,8 @@ // // node scripts/overlay-sync.mjs --check exit 0 if in sync, 1 if drifted // node scripts/overlay-sync.mjs --apply copy fork → overlay + update hashes +// node scripts/overlay-sync.mjs --apply --include +// add one required fork source file // // Options: // --source fork checkout to read from (default: resolved fork) @@ -190,10 +192,10 @@ function observeSource(dir) { // ── Apply mode ────────────────────────────────────────────────────────────── -function apply(forkDir, targetDir, manifestPath) { +function apply(forkDir, targetDir, manifestPath, includedPath) { const { drifted, missingInFork } = check(forkDir, targetDir) - if (drifted.length === 0 && missingInFork.length === 0) { + if (drifted.length === 0 && missingInFork.length === 0 && !includedPath) { console.log("[overlay-sync] already in sync — nothing to do") return 0 } @@ -263,6 +265,17 @@ function apply(forkDir, targetDir, manifestPath) { updated++ } + if (includedPath) { + const forkPath = join(forkDir, includedPath) + const targetPath = join(targetDir, includedPath) + if (!existsSync(forkPath)) throw new Error(`fork source file not found: ${includedPath}`) + mkdirSync(dirname(targetPath), { recursive: true }) + copyFileSync(forkPath, targetPath) + if (manifest?.files) manifest.files[includedPath] = sha256(targetPath) + console.log(` added: ${includedPath}`) + updated++ + } + for (const rel of missingInFork) { console.log(` warning: ${rel} exists in overlay but not in fork (class A amicode-only?)`) } @@ -290,6 +303,7 @@ function parseArgs(argv) { target: flag("target") ?? DEFAULT_OVERLAY_DIR, manifest: flag("manifest") ?? null, sourceBranch: flag("source-branch") ?? DEFAULT_SOURCE_BRANCH, + include: flag("include"), } } @@ -320,7 +334,7 @@ function main(argv) { return 1 } for (const r of guard.recorded) console.log(`[overlay-sync] ${r}`) - return apply(forkDir, targetDir, manifestPath) + return apply(forkDir, targetDir, manifestPath, opts.include) } // ── check (never writes) ── diff --git a/packages/extension/src/amicode_service_runner.ts b/packages/extension/src/amicode_service_runner.ts index f2e7b2a3..f0baa8a7 100644 --- a/packages/extension/src/amicode_service_runner.ts +++ b/packages/extension/src/amicode_service_runner.ts @@ -203,16 +203,19 @@ export async function bootAmicodeServiceRunner(opts: AmicodeServiceRunnerOptions log( `[service-runner] spawning engine ${opts.engineBin} serve --port=${port} (cwd=${cwd}${dbPin ? `, OPENCODE_DB=${dbPin}` : ", OPENCODE_DB=(host env)"})${unarmed ? " UNARMED (the hub's anonymous boundary posture)" : ""}`, ); + const engineEnv = { + ...process.env, + ...opts.engineEnv, + ...(unarmed ? {} : { OPENCODE_SERVER_PASSWORD: password }), + }; + if (unarmed) delete engineEnv.OPENCODE_SERVER_PASSWORD; + const engine: ChildProcess = spawn(opts.engineBin, ["serve", "--port", String(port)], { cwd, - env: { - ...process.env, - // Unarmed = the key is ABSENT, never empty — the fork's route auth only - // engages when the var is set, so an empty value would be a dishonest - // half-posture. - ...(unarmed ? {} : { OPENCODE_SERVER_PASSWORD: password }), - ...opts.engineEnv, - }, + // Unarmed = the key is ABSENT, never empty — the fork's route auth only + // engages when the var is set, so an empty value would be a dishonest + // half-posture. + env: engineEnv, stdio: ["ignore", "pipe", "pipe"], }); let engineLog = ""; diff --git a/packages/extension/test/yellow_chunks_853.test.ts b/packages/extension/test/yellow_chunks_853.test.ts index ff3c4910..b18aa09c 100644 --- a/packages/extension/test/yellow_chunks_853.test.ts +++ b/packages/extension/test/yellow_chunks_853.test.ts @@ -1,17 +1,6 @@ -// Issue #853 — the yellow-chunks port (fork amico/yellow-chunks-on-21 → the -// app overlay). Headless under vitest (the #848/#859/#862 pattern): the pure -// helpers the port added — the docket token builders (6dac9ce04) and the -// shell row detail (85d3eaf04) — plus CSS-grammar regression guards for the -// visual grammar the port establishes (the #349 deletion bug class: the slab, -// the docket slots, the yellow answer chip, the per-scheme prompt-bubble -// seating, the shell command anatomy). +// Issue #853 — the yellow-chunks port's durable docket token helpers. import { describe, expect, test } from "vitest" -import { readFileSync } from "node:fs" -import { join } from "node:path" import { contextDocket, editDocket, shellDocket, type DocketPart } from "../../app-bundle/overlay/packages/ui/src/amicode/docket" -import { shellRowDetail, SHELL_ROW_MAX } from "../../app-bundle/overlay/packages/ui/src/amicode/shell-row" - -const overlay = (...p: string[]) => join(__dirname, "..", "..", "app-bundle", "overlay", ...p) function part(tool: string, input: Record = {}, status = "done", metadata: Record = {}): DocketPart { return { tool, state: { status, input, metadata } } @@ -42,8 +31,10 @@ describe("editDocket (fork 6dac9ce04, ported)", () => { }) test("a file edited with zero recorded diff still appears, without ±", () => { - const docket = editDocket([part("edit", { filePath: "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/q/bare.jl" })]) - expect(docket.tokens).toEqual([{ kind: "file", name: "bare.jl", dir: "/q", additions: undefined, deletions: undefined }]) + expect(editDocket([part("edit", { filePath: "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/q/bare.jl" })])).toEqual({ + tokens: [{ kind: "file", name: "bare.jl", dir: "/q", additions: undefined, deletions: undefined }], + more: 0, + }) }) }) @@ -78,80 +69,3 @@ describe("shellDocket (fork 6dac9ce04, ported)", () => { expect(shellDocket(parts)).toEqual({ commands: 3, failed: 1 }) }) }) - -describe("shellRowDetail (fork 85d3eaf04, ported)", () => { - test("exit surfaces only when it isn't 0", () => { - expect(shellRowDetail({ state: { metadata: { exit: 0 } } }).exit).toBeUndefined() - expect(shellRowDetail({ state: { metadata: { exit: 64 } } }).exit).toBe(64) - }) - - test("duration comes from state.time when both ends exist", () => { - expect( - shellRowDetail({ state: { time: { start: 1000, end: 4250 }, metadata: {} } }).durationMs, - ).toBe(3250) - expect(shellRowDetail({ state: { time: { start: 1000 }, metadata: {} } }).durationMs).toBeUndefined() - }) - - test("output preview is the clamped first line", () => { - const detail = shellRowDetail({ state: { metadata: { output: "line one\nline two" } } }) - expect(detail.preview).toBe("line one") - expect(shellRowDetail({ state: { metadata: { output: "x".repeat(SHELL_ROW_MAX + 10) } } }).preview?.endsWith("…")).toBe(true) - }) - - test("a pending part yields an empty detail", () => { - expect(shellRowDetail({})).toEqual({}) - }) -}) - -describe("yellow-chunks CSS grammar (the #349 deletion bug class)", () => { - const messageCss = () => readFileSync(overlay("packages", "session-ui", "src", "components", "message-part.css"), "utf8") - const markdownCss = () => readFileSync(overlay("packages", "session-ui", "src", "components", "markdown.css"), "utf8") - const polishCss = () => readFileSync(overlay("packages", "app", "src", "design-polish.css"), "utf8") - - test("the answer renders as the prompt bubble's chip (fork 8c91b45fa)", () => { - const css = messageCss() - expect(css).toMatch(/answer-text[^}]*--prompt-bubble-bg/s) - expect(css).toMatch(/answer-text[^}]*margin-left:\s*auto/s) - }) - - test("the docket slots carry the collapsed rows' evidence (fork 6dac9ce04)", () => { - const css = messageCss() - expect(css).toContain('[data-slot="context-tool-group-docket"]') - expect(css).toContain('[data-slot="docket-token"] .docket-file-icon') - expect(css).toContain('[data-slot="docket-token"][data-kind="more"]') - expect(css).toMatch(/docket-diff[^}]*data-sign="add"/s) - expect(css).toContain('[data-slot="docket-failed"]') - }) - - test("the slab: fenced code leaves the prose-card grammar (fork 1bd3ae7d7)", () => { - const css = markdownCss() - expect(css).toContain("--markdown-slab-bg") - expect(css).toMatch(/\[data-prose-fragment\] \[data-component="markdown-code"\][^}]*margin:\s*2px -14px/s) - expect(css).toMatch(/\[data-prose-fragment\] \[data-component="markdown-code"\]::before[^}]*attr\(data-language\)/s) - expect(css).toMatch(/\[data-prose-fragment\] \[data-component="markdown-code"\]\[data-code-kind="shell"\] \.shiki[^}]*background:\s*transparent/s) - }) - - test("the prompt bubble seats per scheme — yellow chip on light, dark box + full yellow border on dark (fork dc116cd6c + 99cb3f93d)", () => { - const css = polishCss() - expect(css).toContain("--prompt-bubble-edge") - expect(css).toMatch(/\[data-color-scheme="dark"\] \{\s*--prompt-bubble-bg: var\(--v2-background-bg-layer-01\);\s*--prompt-bubble-ink: var\(--v2-text-text-base\);\s*--prompt-bubble-edge: var\(--accent\);/s) - }) - - test("the assistant fragments stay on the theme hairline in both schemes (fork 1e2e44b66 net)", () => { - const css = polishCss() - expect(css).toMatch(/\[data-prose-fragment\] \{[^}]*border: var\(--border-width\) solid var\(--v2-border-border-base\);/s) - expect(css).not.toContain("--prose-fragment-edge") - }) - - test("the shell command anatomy slots exist (fork 85d3eaf04)", () => { - const css = messageCss() - for (const slot of ["cmd-dot", "cmd-prompt", "cmd-duration", "cmd-exit", "cmd-preview", "docket-row-file"]) { - expect(css).toContain(`[data-slot="${slot}"]`) - } - expect(css).toMatch(/cmd-dot-pulse/) - }) - - test("the WAAPI merge note records the bubble-lock decision (fork 8c91b45fa)", () => { - expect(polishCss()).toContain("WAAPI merge") - }) -}) From c055055c7039cc340c0a8ce86921ffdc68bdf823 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 20:54:32 -0400 Subject: [PATCH 7/8] fix(sidebar): open text files in editable tabs --- packages/extension/src/sidebar_view.ts | 9 +++++---- packages/extension/test/sidebar_view.test.ts | 9 ++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 63130f28..996201e7 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -393,10 +393,11 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { }, openFileEditor: (p) => { const uri = vscode.Uri.file(p); - // Use vscode.open — delegates to VS Code's file-type detection, - // so images open in the built-in preview, PDFs in a PDF viewer, etc. - // showTextDocument only works for text files (#934). - void vscode.commands.executeCommand("vscode.open", uri); + // Text files open as persistent editable VS Code tabs. Non-text files + // reject showTextDocument and fall back to their registered viewer. + void vscode.window.showTextDocument(uri, { preview: false }).catch(() => + vscode.commands.executeCommand("vscode.open", uri), + ); }, fileOp: (req) => executeFileOp(req), postMessage: (m) => { diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 44b7fe86..0773fe83 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -3315,7 +3315,7 @@ describe("sidebar openFile routes to preview-file bridge message (#934)", () => expect(handlerBlock).toContain("ChatPanel"); }); - it("openFileEditor handler uses vscode.open (works for binary files) — not showTextDocument (#934)", () => { + it("openFileEditor opens an editable text tab before falling back to vscode.open for binary files", () => { const src = readFileSync( resolve(__dirname, "..", "src", "sidebar_view.ts"), "utf8", @@ -3323,11 +3323,10 @@ describe("sidebar openFile routes to preview-file bridge message (#934)", () => // Find the openFileEditor handler const editorIdx = src.indexOf("openFileEditor:"); expect(editorIdx).toBeGreaterThan(-1); - const handlerBlock = src.slice(editorIdx, editorIdx + 200); - // Must use vscode.open (handles images, PDFs, text — any file type) + const handlerBlock = src.slice(editorIdx, editorIdx + 400); + expect(handlerBlock).toContain("showTextDocument(uri, { preview: false })"); + // Keep the viewer fallback for images, PDFs, and other non-text files. expect(handlerBlock).toContain("vscode.open"); - // Must NOT use showTextDocument (breaks on binary files) - expect(handlerBlock).not.toContain("showTextDocument"); }); }); From 4b714e6cfb644b7ed1732405a65b3152da16291e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 20:59:32 -0400 Subject: [PATCH 8/8] fix(sidebar): support VS Code editor thenables --- packages/extension/src/sidebar_view.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 996201e7..1b3e5175 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -395,9 +395,9 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { const uri = vscode.Uri.file(p); // Text files open as persistent editable VS Code tabs. Non-text files // reject showTextDocument and fall back to their registered viewer. - void vscode.window.showTextDocument(uri, { preview: false }).catch(() => - vscode.commands.executeCommand("vscode.open", uri), - ); + void vscode.window + .showTextDocument(uri, { preview: false }) + .then(undefined, () => vscode.commands.executeCommand("vscode.open", uri)); }, fileOp: (req) => executeFileOp(req), postMessage: (m) => {