diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index c6678a81a..7da7abfcc 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -6,7 +6,7 @@ "fork_sha": "d161eb0cfc6d03a53311e083b590005b4161c13a", "upstream_base": "v1.18.29", "upstream_base_sha": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", - "extracted_at": "2026-09-10T07:01:02.938Z", + "extracted_at": "2026-09-10T07:35:41.469Z", "per_package": { "packages/app": { "A": 146, @@ -34,13 +34,13 @@ "D": 0 }, "packages/ui": { - "A": 153, + "A": 155, "M": 62, "D": 0 } }, "counts": { - "overlay_total": 615, + "overlay_total": 617, "deletions": 7, "server_coupled": 47 }, @@ -639,6 +639,7 @@ "packages/ui/src/amicode/context-tree-engine.test.ts": "A", "packages/ui/src/amicode/context-tree-engine.ts": "A", "packages/ui/src/amicode/device-view.tsx": "A", + "packages/ui/src/amicode/docket.ts": "A", "packages/ui/src/amicode/edit-row.test.ts": "A", "packages/ui/src/amicode/edit-row.ts": "A", "packages/ui/src/amicode/entity-rail.tsx": "A", @@ -721,6 +722,7 @@ "packages/ui/src/components/amicode-bridge.tsx": "A", "packages/ui/src/components/amicode-card.tsx": "A", "packages/ui/src/components/amicode-connections-tab.tsx": "A", + "packages/ui/src/components/amicode-docket.tsx": "A", "packages/ui/src/components/amicode-edit-row.tsx": "A", "packages/ui/src/components/amicode-entity-rail.tsx": "A", "packages/ui/src/components/amicode-entity-view.tsx": "A", @@ -973,7 +975,7 @@ "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": "42cc6efaefe9a71dedd12fcb0bf2549453d9a46097cb025cf087064b29d3ccce", + "packages/app/src/design-polish.css": "5c86194cadbb6de279920f99254f82a175d26650041ff9c32fdc8fdd2eaaa75f", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "08179e06ce2d419a2d98acc96025f91c7709062ea9f3ad245e88dc35e75ff9f7", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", @@ -1198,7 +1200,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": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165", + "packages/session-ui/src/components/markdown.css": "9c7954d524e62c34e5f3391dd0174da1535bf3efeffd00d2ff092f781e0439e8", "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", @@ -1206,9 +1208,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": "ae592deb33739738f6a7fc6a3460b7146803bdd05cbbf46f1cfd2c5cec421392", + "packages/session-ui/src/components/message-part.css": "23bd2abcff0ab2993f1a23f399512fe02a3a81e748e55aa931ba6c27d1d277e7", "packages/session-ui/src/components/message-part.test.ts": "7c66430837fa3d2607657e57e35720ae68dc5e84662e12826713a938301eeb7b", - "packages/session-ui/src/components/message-part.tsx": "e94f064b10030622250c8e9bd0c869796cc6dda20fbcb643736c8b4802b1b2b7", + "packages/session-ui/src/components/message-part.tsx": "1c0befaade3eb0452d09463e1a2dbe5fe158ab2e03373d18b07fe4a5726b8d02", "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", @@ -1256,6 +1258,7 @@ "packages/ui/src/amicode/context-tree-engine.test.ts": "afa3199c18c7ca05a19fdf0663a5b4d6e1fd2303e038cf25641c29e254e610f4", "packages/ui/src/amicode/context-tree-engine.ts": "2177d01855d58c5ee7c164c2ed1dac6f4a8a5cc04daea67566f195b02ed1ca06", "packages/ui/src/amicode/device-view.tsx": "1842053e9e25cd5c8076598449e44abf06008940a8f3f39403cbf435b1b8aa8d", + "packages/ui/src/amicode/docket.ts": "d157d2a7a0a8e716c0520c135b24af7eff84841248a50f76e6e5f02f6a83b8f1", "packages/ui/src/amicode/edit-row.test.ts": "7c68fcf083d3854bfb70768b648110e001843a290dcfdc25869c1a4681445b94", "packages/ui/src/amicode/edit-row.ts": "28d5f39470376968e7eabc3f50807a779177e9c9f2081302e4b5eb15306aef1b", "packages/ui/src/amicode/entity-rail.tsx": "fee936650d0f05ab00e46ebb70333b2c92a57ab0da1e7fcee942d7e6b64004b7", @@ -1295,8 +1298,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": "48ec6f0786edf81f163e350904bc2a93b1a55375b8115e688237ce8c2f1a7e64", - "packages/ui/src/amicode/shell-row.ts": "b39dc05d2b77ba6d44456248782b31f6219c891d56700203186a65ccb48319ad", + "packages/ui/src/amicode/shell-row.test.ts": "30d1da7e9bd065c1c93d894b91a17ca73f8e8136c4cc6e1a8dedd95afdfdb13b", + "packages/ui/src/amicode/shell-row.ts": "1c95e2a8bc7ceca53f671429e5f35351188da26e7cb3aa0f9f4fa9fa6a38a0b2", "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", @@ -1338,6 +1341,7 @@ "packages/ui/src/components/amicode-bridge.tsx": "c00ab12a45b9d092abc63a9b259e71bdf3582132aa1c288dcf8a2a520e5a5e80", "packages/ui/src/components/amicode-card.tsx": "a09c4a734c55bf6fef0d04dcc0f0d4fce437e44a8745aa2ac2c936442d9a7d6e", "packages/ui/src/components/amicode-connections-tab.tsx": "d184dfcd5c645332c2cc1c79356e551cd53b67a6b61ed16e78d041de5d13fafe", + "packages/ui/src/components/amicode-docket.tsx": "060f2be2b0a91b38cc122f2a77baf9939a8eda19bae683c7814cfbf281ec9b3c", "packages/ui/src/components/amicode-edit-row.tsx": "99c8c4e90ea02be21f74603c5a5ac1e2c030f487d62ce9da972c99dff505d892", "packages/ui/src/components/amicode-entity-rail.tsx": "5377561b952d9e6bd9bd6a16cec91a37770f14195bb7bd1cd294eb20a1e78a3d", "packages/ui/src/components/amicode-entity-view.tsx": "1ff46b6abdca65992606755e0d5e516483bbd8f3f8cef54b81cb6faa08d895dc", @@ -1353,7 +1357,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": "0ce6525dd2cb8c1b04c7779ae477417a7776ec83768b8b028d52817e734587a9", + "packages/ui/src/components/amicode-shell-row.tsx": "fba08c4342bb775c099b1d71549708b7509ea29d83ac57401c99210a274f72c1", "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", @@ -1552,7 +1556,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": "718264c48becae24f8c55b0ef61684a26a9a12762edad9526c0b8caf9f6b097f", + "packages/app/src/pages/session/timeline/message-timeline.tsx": "2c0214ee9ee240340c4917287650da8d299c9bcc4eabd2fcc73a04c5bfa4749b", "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/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index e74ec5936..8fc3582b3 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -151,27 +151,28 @@ } /* ── prompt bubble ground — per-scheme REALISATION ── - 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). */ + 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. */ :root, [data-color-scheme="light"] { - --prompt-bubble-bg: var(--v2-background-bg-inverse); - --prompt-bubble-ink: var(--v2-text-text-inverse); + --prompt-bubble-bg: var(--accent); + --prompt-bubble-ink: var(--accent-ink); + --prompt-bubble-edge: var(--accent-edge-ink); } [data-color-scheme="dark"] { - --prompt-bubble-bg: var(--v2-background-bg-layer-02); + --prompt-bubble-bg: var(--v2-background-bg-layer-01); --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-02); + --prompt-bubble-bg: var(--v2-background-bg-layer-01); --prompt-bubble-ink: var(--v2-text-text-base); + --prompt-bubble-edge: var(--accent); } } @@ -232,9 +233,14 @@ /* ── 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. */ + 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.) */ [data-slot="text-part-body"]:has([data-prose-fragment]) { display: flex; flex-direction: column; @@ -258,6 +264,12 @@ 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; 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 cd1879bb7..600b4da3d 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,6 +879,34 @@ 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 @@ -2575,7 +2603,8 @@ export function MessageTimeline(props: { }} /> {/* amicode#271: bubble inside the header — naturally below the - title row + chip rail */} + title row + chip rail. The element PERSISTS across hand-offs; + the lock-in merge runs via WAAPI in the effect below. */} {(bubble) => (
@@ -2606,7 +2636,8 @@ export function MessageTimeline(props: {
- {/* amicode#271: no-header fallback (new untitled sessions only) */} + {/* amicode#271: no-header fallback (new untitled sessions only) — + same persistent chip, same WAAPI merge */} {(_) => { const bubble = () => activeBubble()! @@ -2618,20 +2649,21 @@ export function MessageTimeline(props: { > diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css index f1871c1ad..d86a6d10c 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,6 +6,12 @@ --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; @@ -185,6 +191,55 @@ 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 8b8167ea9..67c644b9a 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`): 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. */ + 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. */ [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: none; + border: var(--border-width, 1px) solid var(--prompt-bubble-edge, transparent); padding: 10px 16px; border-radius: var(--radius-lg); - /* 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). */ + /* 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. */ [data-highlight="file"], [data-highlight="agent"] { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); @@ -670,6 +670,180 @@ } } +/* 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; @@ -1280,8 +1454,18 @@ 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"] { - color: var(--v2-text-text-base); + 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); } } } 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 4d932c801..dd98294e7 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 { shellRowLabel } from "@opencode-ai/ui/amicode-shell-row" +import { shellRowDetail, 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,6 +696,7 @@ 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, @@ -1146,6 +1147,63 @@ 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)) @@ -1350,6 +1408,7 @@ 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) @@ -1386,29 +1445,36 @@ 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={ + + } + > + + @@ -1422,6 +1488,15 @@ 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 (
@@ -1432,8 +1507,15 @@ export function ContextToolGroup(props: { {trigger().title} - - {trigger().subtitle} + + {trigger().subtitle} + + } + > + @@ -1455,6 +1537,23 @@ 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 @@ -1466,6 +1565,7 @@ 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?.() @@ -1501,7 +1601,13 @@ 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" > - {count()} {count() === 1 ? "command" : "commands"} + {tally().commands} {tally().commands === 1 ? "command" : "commands"} + 0}> + {" "} + + · {tally().failed} {tally().failed === 1 ? "failed" : "failed"} + + @@ -1512,26 +1618,44 @@ 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}
+
@@ -1546,6 +1670,16 @@ 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 @@ -1575,6 +1709,9 @@ 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?.() @@ -1605,15 +1742,25 @@ 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" > - {count()} {count() === 1 ? "change" : "changes"} - 0}> - {" "} - in {fileCount()} {fileCount() === 1 ? "file" : "files"} + 0 || docket().more > 0} + fallback={ + <> + {count()} {count() === 1 ? "change" : "changes"} + 0}> + {" "} + in {fileCount()} {fileCount() === 1 ? "file" : "files"} + + 0}> + {" "} + + + + } + > + - 0}> - -
@@ -1623,6 +1770,7 @@ 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", @@ -1635,9 +1783,16 @@ export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSize
- - {label()} - + + {label()} + + } + > + + {(d) => } diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/docket.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/docket.ts new file mode 100644 index 000000000..efcfc0939 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/docket.ts @@ -0,0 +1,102 @@ +import { editRowDiff, editRowFilePath } from "./edit-row" + +/* The docket — collapsed tool-group rows carry their EVIDENCE inline (Aaron + * 2026-09-04): file tokens with ±, pattern tokens with repeat counts, shell + * tallies. Pure and testable; the group components only render. A docket is a + * bounded list: the first `max` unique tokens plus a `more` count, so a + * hundred-file run still fits on one line. Parts are structural (tool name + + * state) — the same shape edit-row.ts accepts — so tests need no SDK types. + * + * Lives beside edit-row.ts (same import seam) and takes its basename inline + * (core's getFilename is a path.split("/").pop()) so the module stays free of + * cross-package imports — the extension vitest lane runs it headless. */ + +/** Basename of a path (core's getFilename semantics: everything past the + * final slash — a trailing slash yields the empty string, as upstream). */ +function getFilename(path: string): string { + return path.split("/").pop() ?? path +} + +export type DocketPart = { + tool: string + state: { status?: string; input?: Record; metadata?: Record } +} + +export type DocketToken = + | { kind: "file"; name: string; dir?: string; additions?: number; deletions?: number } + | { kind: "pattern"; text: string; count: number } + | { kind: "dir"; text: string } + +export type Docket = { tokens: DocketToken[]; more: number } + +/** Cap a token list: keep the first `max`, report the rest as `more`. */ +function cap(tokens: DocketToken[], max: number): Docket { + if (tokens.length <= max) return { tokens, more: 0 } + return { tokens: tokens.slice(0, max), more: tokens.length - max } +} + +/** Edited-files docket: one token per unique target path, ± summed across + * repeat edits of the same file. Parts without a path (pending) skip. */ +export function editDocket(parts: DocketPart[], max = 3): Docket { + const byPath = new Map() + for (const part of parts) { + const path = editRowFilePath(part) + if (!path) continue + const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined + const diff = editRowDiff(part) + const entry = byPath.get(path) ?? { dir: dir ?? "", additions: 0, deletions: 0 } + entry.additions += diff?.additions ?? 0 + entry.deletions += diff?.deletions ?? 0 + byPath.set(path, entry) + } + const tokens: DocketToken[] = [...byPath.entries()].map(([path, d]) => ({ + kind: "file", + name: getFilename(path), + dir: d.dir || undefined, + additions: d.additions || undefined, + deletions: d.deletions || undefined, + })) + return cap(tokens, max) +} + +/** Explored docket: reads surface as file tokens, searches as their pattern + * (repeat searches of one pattern merge with a count), lists as directory + * tokens. Order follows the run. */ +export function contextDocket(parts: DocketPart[], max = 3): Docket { + const tokens: DocketToken[] = [] + const seen = new Map() + const push = (key: string, token: DocketToken) => { + if (seen.has(key)) { + const existing = seen.get(key)! + if (existing.kind === "pattern") existing.count++ + return + } + seen.set(key, token) + tokens.push(token) + } + for (const part of parts) { + const input = part.state.input ?? {} + const filePath = typeof input.filePath === "string" ? input.filePath : undefined + const path = typeof input.path === "string" ? input.path : "/" + const pattern = typeof input.pattern === "string" ? input.pattern : undefined + switch (part.tool) { + case "read": + if (filePath) push(filePath, { kind: "file", name: getFilename(filePath) }) + break + case "glob": + case "grep": + if (pattern) push(pattern, { kind: "pattern", text: pattern, count: 1 }) + break + case "list": + push(path, { kind: "dir", text: path }) + break + } + } + return cap(tokens, max) +} + +/** Worked-in-shell docket: a tally, plus the failure count when it isn't zero. */ +export function shellDocket(parts: DocketPart[]): { commands: number; failed: number } { + const failed = parts.filter((part) => part.state.status === "error").length + return { commands: parts.length, failed } +} 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 bfb383c32..e9df968e0 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, shellRowLabel, SHELL_ROW_MAX } from "./shell-row" +import { clampShellLabel, shellRowDetail, shellRowLabel, SHELL_ROW_MAX } from "./shell-row" // Regression coverage for "a constant error not going away" (2026-07-29). // @@ -58,3 +58,27 @@ 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 400bfed9b..8b4cde4f0 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,9 +22,37 @@ 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-docket.tsx b/packages/app-bundle/overlay/packages/ui/src/components/amicode-docket.tsx new file mode 100644 index 000000000..45b6c663b --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/components/amicode-docket.tsx @@ -0,0 +1,3 @@ +// AMICODE: re-export shim (same wildcard-export pattern as amicode-card.tsx). +// Logic lives in ../amicode/docket.ts. +export { contextDocket, editDocket, shellDocket, type DocketPart, type DocketToken, type Docket } from "../amicode/docket" 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 2750a3dd2..fc06a303e 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 { shellRowLabel } from "../amicode/shell-row" +export { shellRowDetail, shellRowLabel } from "../amicode/shell-row" diff --git a/packages/extension/test/yellow_chunks_853.test.ts b/packages/extension/test/yellow_chunks_853.test.ts new file mode 100644 index 000000000..ff3c4910f --- /dev/null +++ b/packages/extension/test/yellow_chunks_853.test.ts @@ -0,0 +1,157 @@ +// 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). +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 } } +} + +function edit(filePath: string, additions = 0, deletions = 0): DocketPart { + return part("edit", { filePath }, "done", { filediff: { additions, deletions } }) +} + +describe("editDocket (fork 6dac9ce04, ported)", () => { + test("one token per unique file, ± summed across repeat edits", () => { + const docket = editDocket([edit("/a/b/solve.jl", 5, 1), edit("/a/b/solve.jl", 7, 2), edit("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/x/spec.toml", 4, 0)]) + expect(docket.more).toBe(0) + expect(docket.tokens).toEqual([ + { kind: "file", name: "solve.jl", dir: "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/a/b", additions: 12, deletions: 3 }, + { kind: "file", name: "spec.toml", dir: "/x", additions: 4, deletions: undefined }, + ]) + }) + + test("caps at max and reports the rest", () => { + const docket = editDocket(["a.ts", "b.ts", "c.ts", "d.ts"].map((f) => edit(`/p/${f}`)), 2) + expect(docket.tokens).toHaveLength(2) + expect(docket.more).toBe(2) + }) + + test("pending parts without a path contribute nothing", () => { + expect(editDocket([part("edit")])).toEqual({ tokens: [], more: 0 }) + }) + + 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 }]) + }) +}) + +describe("contextDocket (fork 6dac9ce04, ported)", () => { + test("reads become file tokens, searches merge patterns, lists become dirs", () => { + const parts = [ + part("read", { filePath: "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/a/one.jl" }), + part("grep", { pattern: "fidelity", path: "/a" }), + part("grep", { pattern: "fidelity", path: "/a" }), + part("list", { path: "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/a/src" }), + ] + expect(contextDocket(parts)).toEqual({ + tokens: [ + { kind: "file", name: "one.jl" }, + { kind: "pattern", text: "fidelity", count: 2 }, + { kind: "dir", text: "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/a/src" }, + ], + more: 0, + }) + }) + + test("caps and counts the tail", () => { + const docket = contextDocket(["x", "y", "z", "w"].map((p) => part("read", { filePath: `/${p}.jl` })), 2) + expect(docket.tokens).toHaveLength(2) + expect(docket.more).toBe(2) + }) +}) + +describe("shellDocket (fork 6dac9ce04, ported)", () => { + test("counts commands and failures", () => { + const parts = [part("bash"), part("bash"), part("bash", {}, "error")] + 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") + }) +})