diff --git a/CONTEXT.md b/CONTEXT.md index edb7344b0..d9c51841a 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/0013-preview-workspace-split-panes.md b/docs/adr/0013-preview-workspace-split-panes.md index 608cc3e20..03211c0c3 100644 --- a/docs/adr/0013-preview-workspace-split-panes.md +++ b/docs/adr/0013-preview-workspace-split-panes.md @@ -8,10 +8,16 @@ 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. **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. 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. diff --git a/docs/adr/0014-dockable-work-column.md b/docs/adr/0014-dockable-work-column.md new file mode 100644 index 000000000..99a02be8f --- /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. diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 29b2caebe..dace6c6bb 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", @@ -977,10 +980,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": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", "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 +1048,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", @@ -1083,9 +1086,10 @@ "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": "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 +1135,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", @@ -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", @@ -1205,7 +1210,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 +1218,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 +1309,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 +1368,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 +1443,14 @@ "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": "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", @@ -1495,12 +1501,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 +1568,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 19128cf8c..b22536e69 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 3eab0b92a..9a279a62e 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 32992d8a7..540af98d7 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 d193f28bb..a40e6125a 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 PreviewLeafEdges = { + top: boolean + right: boolean + bottom: boolean + left: boolean +} - const [fileStates, setFileStates] = createStore({}) +type PreviewSortableSource = { + id: string | number + sortable: { initialGroup?: string | number; group?: string | number; index: number } +} - const [zoom, setZoom] = createSignal(100) +class PreviewRailSnapModifier extends Modifier Iterable }> { + apply(operation: DragOperation) { + const source = operation.shape?.initial.boundingRectangle + if (!source) return operation.transform - 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 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 - const getFileState = (path: string): PreviewFileState => { - return fileStates[path] ?? { mode: "preview", scrollPosition: 0, unsavedContent: null } + 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>({}) + 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 [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 previewDragDrop: PreviewDrop | undefined + let previewDragFocus: + | { target: HTMLElement; scroller?: HTMLElement; scrollTop?: number } + | undefined - const setFileState = (path: string, update: Partial) => { - const defaults: PreviewFileState = { mode: "preview", scrollPosition: 0, unsavedContent: null } - setFileStates(path, (prev) => ({ - ...defaults, - ...prev, - ...update, - })) + const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs) + + 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?.() }) - // ─── 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) + } - // 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 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, + ): 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 + return { + leafID: leaf.id, + position: "center", + targetIndex: insertionIndex, + 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 isSelected = (path: string) => previewLeafContaining(workspace().tree, path)?.selectedPath === path + + const rememberPreviewFocus = (path: string) => { + const activeElement = document.activeElement + 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 + } + previewDragFocus = undefined + } + + const sortableSource = (source: unknown): PreviewSortableSource | undefined => { + if (!source || typeof source !== "object" || !("sortable" in source)) return undefined + return source as PreviewSortableSource + } + + const handlePreviewDragStart = (event: DragStartEvent) => { + const source = sortableSource(event.operation.source) + if (!source) return + document.documentElement.toggleAttribute("data-preview-tab-dragging", true) + } + + 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 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 + }) + }) + } + + 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() + } + + 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) => { + 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 + } + 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) => } + + + + +
{ + for (const path of leaf.tabs) setHostMounts(path, element) + }} + class="preview-pane-content" + /> + + +
+ +
+ ) + } + + return ( + <> +
+ + + + + + {(path) => ( + )} + +
+ 0} + fallback={ +
+ +

Select a file from the sidebar

+
+ } + > + + event.target instanceof Element && !!event.target.closest('[data-slot="tabs-trigger-close-button"]'), + }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0.05 } }), + Feedback.configure({ dropAnimation: null }), + ]} + modifiers={[ + PreviewRailSnapModifier.configure({ + rails: () => + Array.from(leafElements.values(), (leaf) => leaf.querySelector('[data-slot="tabs-list"]')).filter( + (rail): rail is HTMLElement => !!rail, + ), + }), + ]} + onDragStart={handlePreviewDragStart} + onDragMove={handlePreviewDragMove} + onDragEnd={handlePreviewDragEnd} + > +
+ {(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/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 000000000..d4bbee9bc --- /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 000000000..6f8b0a0be --- /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/context/layout.tsx b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx index 35609af0f..09ab26227 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 8fc3582b3..476bdcc95 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,250 @@ --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); +} + +html[data-preview-tab-dragging], +html[data-preview-tab-dragging] * { + cursor: default !important; +} + +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; +} +#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 +388,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 +469,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 +495,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 +515,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 +529,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 +561,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 +588,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 +607,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 +631,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 +656,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 +682,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 +724,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 2f1aa408c..404975bb5 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 1c0c60f6f..ac3d00ecd 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 64f77e59c..ef5806dd1 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 c3104919a..16b29e57c 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 100170480..07cd937c8 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 a2cfdc4cf..4793a11ef 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 600b4da3d..cd1879bb7 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/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 000000000..fb99e8a5c --- /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/overlay/packages/session-ui/src/components/markdown.css b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css index d86a6d10c..f1871c1ad 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 67c644b9a..8b8167ea9 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 dd98294e7..4d932c801 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 e9df968e0..bfb383c32 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 8b4cde4f0..400bfed9b 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 fc06a303e..2750a3dd2 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" diff --git a/packages/app-bundle/scripts/overlay-sync.mjs b/packages/app-bundle/scripts/overlay-sync.mjs index fbfbac299..6e3e1094a 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 f2e7b2a31..f0baa8a73 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/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 63130f284..1b3e51759 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 }) + .then(undefined, () => 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 44b7fe864..0773fe83d 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"); }); }); diff --git a/packages/extension/test/yellow_chunks_853.test.ts b/packages/extension/test/yellow_chunks_853.test.ts index ff3c4910f..b18aa09c8 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") - }) -})