@plannotator/ui 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/HANDOFF.md +43 -1
  2. package/README.md +81 -4
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/CodeFilePopout.tsx +6 -4
  6. package/components/ComposerTokenTextarea.tsx +43 -2
  7. package/components/DocBadges.tsx +47 -35
  8. package/components/ImageZoomStage.tsx +32 -4
  9. package/components/Settings.tsx +2 -1
  10. package/components/TableOfContents.tsx +2 -1
  11. package/components/Viewer.tsx +43 -17
  12. package/components/chat/ChatComposer.tsx +192 -0
  13. package/components/chat/ChatFrame.tsx +524 -0
  14. package/components/chat/ChatTranscript.tsx +422 -0
  15. package/components/chat/chatMarkdown.ts +436 -0
  16. package/components/chat/chatRecipes.tsx +39 -0
  17. package/components/chat/chatWindowStore.ts +196 -0
  18. package/components/chat/index.ts +72 -0
  19. package/components/diagram/DiagramCanvas.tsx +7 -0
  20. package/components/diagram/DiagramComposer.tsx +15 -3
  21. package/components/diagram/DiagramViewer.tsx +1 -0
  22. package/components/docBadgesWidth.ts +29 -0
  23. package/components/imageZoom.ts +10 -0
  24. package/components/inbox-thread/FileName.tsx +118 -0
  25. package/components/inbox-thread/GuideCard.tsx +39 -0
  26. package/components/inbox-thread/ReaskNote.tsx +25 -0
  27. package/components/inbox-thread/SkillsLine.tsx +40 -0
  28. package/components/inbox-thread/Tip.tsx +105 -0
  29. package/components/inbox-thread/icons.tsx +262 -0
  30. package/components/inbox-thread/reask.ts +222 -0
  31. package/hooks/useActiveSection.ts +46 -63
  32. package/hooks/useAnnotationHighlighter.ts +40 -1
  33. package/hooks/useFileBrowser.ts +4 -3
  34. package/inbox-thread.css +4542 -0
  35. package/package.json +10 -3
  36. package/styles.css +1 -1
  37. package/theme.css +1 -0
  38. package/themes/tokyo-night-moon.css +34 -0
  39. package/utils/aiChatFormat.ts +32 -11
  40. package/utils/headingScrollSpy.ts +19 -6
  41. package/utils/syntaxTheme.ts +1 -0
  42. package/utils/themeRegistry.ts +11 -0
package/HANDOFF.md CHANGED
@@ -234,6 +234,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
234
234
  | `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
235
235
  | `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
236
236
  | `utils/mermaid-math-slot` | Alias target only: what a host redirects Mermaid's own `katex` import to, so `$$` labels in diagrams typeset through the math slot and the host build carries one KaTeX chunk. Never import it yourself. See "Lazy renderers and eager entries", item 2. |
237
+ | `components/chat` (`ChatFrame`, `ChatFrameLauncher`, `ChatWindowStore`, `ChatTranscript`, `ChatMarkdown`, `ChatComposer` and the hooks and types beside them) | The shared floating chat window: screen code only. The host owns the conversation, the transport and anything saved. Add the folder and `components/ui/{button,textarea}.tsx` to your `@source`, and define the theme tokens listed in `components/chat/index.ts`. See "Floating chat window" in `README.md`. *(Published in ui 0.56.0.)* |
237
238
  | `utils/identity-tater` | Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with `identityProvider` should not. |
238
239
  | `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration. Omit `utils/mermaid-eager` for the lazy path with retry, which is what Plannotator itself does since 0.40.0 (Mermaid 12); import it to register the runtime in your entry chunk at startup. See "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)". |
239
240
  | `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `buildMermaidShadow`, `DEFAULT_MERMAID_SHADOW_AMOUNT`), `utils/diagramShadow` and `utils/cssColor` | Theme-aware diagram configuration: the pure token-to-`themeVariables` mapping with its contrast guard, the palette-derived node shadow (`options.shadowAmount`, default 0.7 of Mermaid's own), the document token reader, and the cached per-`(palette, mode, shadow amount)` `initialize` step `MermaidBlock` runs before each render. Additive; with no theme tokens on the document the static `MERMAID_CONFIG` stays in force. See "Theme-aware Mermaid diagrams (0.40.0)". |
@@ -1770,6 +1771,7 @@ Additive, one optional `Viewer` prop; Plannotator passes none, so its render is
1770
1771
  Additive, one optional `Viewer` prop; Plannotator's plan review and annotate pass none, so their render is unchanged. Added for the Plannotator Inbox (approval "inbox-message-comments", 2026-10-19), where the person comments on the words of an agent's message.
1771
1772
 
1772
1773
  - **`textComments?: boolean`** on `Viewer`, with `answerOnly`: a text selection opens the selection toolbar again, comment-only (the toolbar's `commentOnly`: Comment and the one-click 👍, no Delete, no label picker), with its comment composer, and `annotations` are drawn as highlights (the highlighter is enabled). Question cards (and a host's `renderQuestionCard`) are wrapped `annotation-exclude`, so their words are never commented on. Nothing else of authoring comes back: no pinpoint, code-block toolbar, Global comment, Copy or Vim. Ignored without `answerOnly` and under `readOnly`. Inside, `selectionCommentsOn = !authoringOff || (answerOnly && textComments && !readOnly)` replaces `!authoringOff` for the highlighter and the selection toolbar, its composer and its label picker only.
1774
+ - **Diagrams follow it (ui 0.56.0; no API change).** Under the same condition a Mermaid or Graphviz fence takes comments on its parts, inline and in the popout, and a host's `onAskAI` reaches its composer as it reaches the text composer: `onAddAnnotation` gets the row `DiagramBlock` has always made (`diagramAnchor`, the fence's `blockId`, the part's label as `originalText`). A host that passes `textComments` must keep that row and pass it back in `annotations`; plain `answerOnly` and `readOnly` documents stay read-only, unchanged. Two touch fixes reach every host that comments on diagrams: a second finger cancels the press (it opened a composer), and the composer lifts to stay inside a canvas that would clip it.
1773
1775
 
1774
1776
  ## Dependency bumps: KaTeX 0.18, @pierre/diffs 1.5.2, Mermaid 12.2 (published in ui 0.54.0)
1775
1777
 
@@ -1845,6 +1847,12 @@ Everything below is opt-in and byte-identical when a host passes none of it.
1845
1847
 
1846
1848
  ## Publishing & versioning
1847
1849
 
1850
+ - **ui 0.56.0: ui only. `@plannotator/core` stays at 0.25.14, and ui keeps pinning it exactly.** Additive: no `exports` subpath is removed, no dependency or peer range changed (`postcss` is a devDependency for the sheet generator). Core files under `packages/core` did change since v0.28.37 (Inbox voice, projects, `shortAgentName`), but every `@plannotator/core` subpath ui imports exists in the tag, and ui imports none of the names those changes added. Cut from `main` at `a5707a547` plus the version bump. What is new, and what a host does:
1851
+ 1. **Floating chat window** (#2452), `@plannotator/ui/components/chat`: `ChatFrame`, `ChatFrameLauncher`, `ChatWindowStore`, `ChatTranscript`, `ChatMarkdown`, `ChatComposer`, `chatMarkdownHtml`, `personMarkdownHtml`, and the hooks and types beside them. Required of a host that uses it: define the nine theme tokens listed in `components/chat/index.ts`, and add `components/chat` and `components/ui/{button,textarea}.tsx` to Tailwind `@source`. See README "Floating chat window".
1852
+ 2. **Inbox thread parts** (`components/inbox-thread/*`: `GuideCard`, `ReaskNote`, `SkillsLine`, `FileName`, `Tip`, `icons`, `reask`) and the generated **`@plannotator/ui/inbox-thread.css`**. Import the sheet outside any cascade layer, after the base reset, and wrap the parts in `<div className="pn-inbox" data-pn-tooltip-root="">`. See "Inbox thread stylesheet". The parts are unstable until the set is complete.
1853
+ 3. **Contents rail** (`components/MarkdownContentsRail`, `DeferredMarkdownContentsRail`, `DocumentContents`, `hooks/useHeadingScrollSpy`) and **`@plannotator/ui/contents-rail.css`**, moved from Workspaces. A host that has its own copy of the rail switches to these and imports the sheet outside any layer.
1854
+ 4. **Diagram comments** (#2469, no API change): with `Viewer` `textComments`, a Mermaid or Graphviz fence takes comments on its parts. A host that passes `textComments` must keep the `DiagramBlock` annotation row and pass it back in `annotations`.
1855
+ 5. **Smaller, no action:** `Viewer` `#anchor` and badge fixes, Windows path names, Tokyo Night Moon palette, Ask AI output sanitizing, `CodeSnippetPreview` and `useHoverPreview` extracted from `InlineMarkdown`.
1848
1856
  - **ui 0.55.0: ui only. `@plannotator/core` is unchanged at 0.25.14** (nothing under `packages/core` moved since v0.28.37), so ui publishes alone and keeps pinning core `0.25.14` exactly. No guides.show deploy is needed. It is cut from `main` at `f103b6703` plus the version bump, and packed with `bun pm pack` from a clean `bun install --frozen-lockfile` at Bun 1.4.4. **No export name is removed** (checked name by name against ui 0.54.0 on npm), and peer ranges are unchanged. Hosts must act on these:
1849
1857
  1. **KaTeX 0.19.** `katex` is `^0.19.0` (was `^0.18.11`, which npm marks deprecated). Rendering is unchanged, but a host that serves its own `katex.min.css` should move it to 0.19. Mermaid 12.2.0 still declares `katex ^0.18.0`, so without action the install holds two KaTeX copies. Add a root override `"katex": "^0.19.0"` (`pnpm.overrides`, `overrides` or `resolutions`), or point Mermaid's `katex` at `utils/mermaid-math-slot`. See "KaTeX 0.19".
1850
1858
  2. **`HtmlViewer` `sandbox?: string`, for Workspaces.** The srcdoc frame's whole `sandbox` attribute. Workspaces passes `sandbox="allow-scripts"` so a shared document's `alert` / `confirm` / `prompt` cannot draw over its origin, and can drop its pnpm patch. Absent, the default stays `"allow-scripts allow-modals"`. See "`HtmlViewer` `sandbox`".
@@ -1891,7 +1899,7 @@ Everything below is opt-in and byte-identical when a host passes none of it.
1891
1899
  - The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
1892
1900
  - UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
1893
1901
  - **`--provenance` only works from a supported CI environment (GitHub Actions OIDC)** — a local publish fails with `Automatic provenance generation not supported for provider: null`. Until a CI publish job exists for these two packages, local publishes drop the flag. Publishing under `--tag next` first lets the consumer preflight before `npm dist-tag add <pkg>@<version> latest` promotes it.
1894
- - `styles.css` is built by the `prepack` script (`bun run build:css`) so the published tarball always carries fresh precompiled CSS; since 0.33.0 `prepack` also runs `build:bridge-assets`, which generates the gitignored `bridge-script.asset.js` and `bridge-script.lite.ts` beside their source. Both are in `files`, so a tarball built without `prepack` (a hand-rolled `npm pack --ignore-scripts`) would ship export subpaths that resolve to nothing; always build with `bun pm pack`.
1902
+ - `styles.css` is built by the `prepack` script (`bun run build:css`) so the published tarball always carries fresh precompiled CSS; since 0.33.0 `prepack` also runs `build:bridge-assets`, which generates the gitignored `bridge-script.asset.js` and `bridge-script.lite.ts` beside their source, and it now runs `build:inbox-thread-css`, which generates the gitignored `inbox-thread.css` from `packages/inbox/inbox.css`. All of them are in `files`, so a tarball built without `prepack` (a hand-rolled `npm pack --ignore-scripts`) would ship export subpaths that resolve to nothing; always build with `bun pm pack`. `smoke:package` fails if the sheet is missing from the tarball or a consumer cannot import it.
1895
1903
  - There is **no CI publish job for these two packages yet** — first publish is manual from `main` after merge. (Wiring a CI publish job is a follow-up.)
1896
1904
 
1897
1905
  ---
@@ -2561,6 +2569,40 @@ Slice 1 of the Inbox thread extract (plan record, not in this repo). These pure
2561
2569
 
2562
2570
  Publish core first, then ui, as always. These subpaths need no ui bump until ui imports them.
2563
2571
 
2572
+ ## Inbox thread stylesheet: `@plannotator/ui/inbox-thread.css` (ui 0.56.0; additive, no core change)
2573
+
2574
+ The parts in `components/inbox-thread/` draw with the Inbox's `ib-` classes. `packages/inbox/inbox.css` stays their only source. `scripts/build-inbox-thread-css.ts` generates `inbox-thread.css` from it at `prepack`, the pattern of `bridge-script.asset.js`: gitignored, in `files`, exported as `./inbox-thread.css`. It is the whole sheet in the source's order, about 560 KB raw and 79 KB gzipped (511 KB and 78 KB after a host's bundler minifies it). A thread-only subset can come later.
2575
+
2576
+ **What generation changes.** The comments go, and whatever would reach a host page outside `.pn-inbox`. `tests/inbox-thread-css.test.ts` runs the generator over the real `inbox.css` and checks each of these from the outside:
2577
+
2578
+ | In `inbox.css` | Reach | In the sheet |
2579
+ |---|---|---|
2580
+ | `html, body, #root` (`@layer base`) | height and margin of the page | dropped |
2581
+ | `html:has(.pn-inbox), body:has(.pn-inbox)` (phone block) | the page's background | dropped |
2582
+ | `:root[data-ib-keyboard-pin]:has(.pn-inbox)` (phone block) | `overflow: hidden` on the page while the Inbox pins the keyboard | dropped |
2583
+ | `html.ib-theme-instant` | transitions on `html` | dropped; its `*` forms are scoped |
2584
+ | `body:has(.pn-inbox) :is(input…, textarea, select)` (phone block) | every field on the page at `16px !important` | scoped |
2585
+ | `pre code.pn-code`, `[data-local-link="unavailable"]`, `kbd[data-keycap]::after`, `[data-open-in-menu] …`, `[data-comment-popover] …`, `[data-full-view-menu] …`, `[data-thread-owner] b` | ui's own elements and attributes, anywhere on the page | scoped |
2586
+ | `.tp`, `.tp-*`, `.pm-*` | unprefixed classes a host may also use | scoped |
2587
+ | `.ib-*`, `dialog.ib-*`, `::highlight(ib-*)` and the rest | prefixed, but matching outside the root | scoped |
2588
+ | `@keyframes tp-draw` | a global name without the prefix | `ib-tp-draw`, with its use |
2589
+ | `:root` and `:root.light` token blocks | `--ib-*` only, checked rule by rule | kept |
2590
+
2591
+ Scoping prefixes `:where(.pn-inbox)`, after any leading page element (`:root.light :where(.pn-inbox) .ib-linknote`). It adds no specificity, so the sheet's own cascade is the source's, rule for rule. The sheet has no `@font-face`, no `@import` and no `url()` but one inline `data:` mask. It names two layers, Tailwind's `base` and its own `ib-type`. A new at-rule in `inbox.css` that the generator does not know fails `prepack`, so someone decides what it does in a host first. So do nested CSS, `body > x`, and a renamed keyframe name held in a custom property, which the rename cannot follow. The generator reads the sheet with postcss, a devDependency of this package only: it runs at `prepack` and nothing at runtime needs it.
2592
+
2593
+ **What a host does.**
2594
+
2595
+ 1. Import the sheet **outside any cascade layer, after the host's base reset**, as with `contents-rail.css`. In Workspaces that is next to `contents-rail.css` in `src/styles.css`, not in `src/plannotator/plannotator.css`: inside `layer(plannotator)` the sheet would lose to Tailwind's preflight in `base`.
2596
+ 2. Keep `styles.css`. The sheet reads the theme tokens (`--background`, `--card`, `--muted`, `--border`, `--primary`, `--ring`, `--font-sans`, `--font-mono` and the rest) and defines none of them.
2597
+ 3. Wrap the parts in one root: `<div className="pn-inbox" data-pn-tooltip-root="">`. Both on the same element, or a `Tip` portals outside the root and loses its look. The root sets `height: 100%`, `position: relative`, the type and `background: var(--ib-ground)`. The Inbox's pane paints `--card` over that, so a host matches it with `background: var(--card)` on its root, or uses its panel's own surface. In a scrolling panel `height: 100%` stops the background short of tall content: set `height: auto` on the root, or make the root the scroller.
2598
+ 4. Load the fonts, as for `styles.css`: Inter and Geist Mono, or your own under `--font-sans` and `--font-mono`. A Tailwind host sets both on its root: Tailwind's `theme` layer defines them too, and it beats `styles.css` in `layer(plannotator)`, so without this the parts draw in the system font.
2599
+ 5. Set the mode on `<html>` the way `ThemeProvider` does: the light half of the tokens reads `:root.light`. `styles.css` also holds every transition at 0 s until `<html>` has `.transitions-ready`, which `ThemeProvider` adds after mount.
2600
+ 6. Portal into the root. The `ib-` rules match only inside `.pn-inbox`, so a popup a part sends to `document.body` has no look.
2601
+
2602
+ **What the Inbox's window owns and a host does not get.** `--ib-vvh`, `--inbox-text-size`, `--sidebar-width`, `data-view`, `data-ib-keyboard` and `.ib-phone` come from the Inbox's own code. Their rules ship, inert, with fallbacks where the source has them. `@container ib-thread` needs the Inbox's `.ib-pbody` around the parts, and `@container ib-ask` its `.ib-askline`; without them those rules do not apply.
2603
+
2604
+ **Viewport queries.** The sheet keeps the Inbox's media queries, and they measure the window, not the panel. For the parts in the folder today the one that matters is the phone block, `@media (max-width: 767px), (max-height: 500px) and (max-width: 1023px) and (pointer: coarse)`. Under it `.ib-att-t` takes the full width and 56 px of height, and `.pn-inbox` paints `--card`. Most of its 712 selectors belong to the Inbox's window or to parts that have not moved yet.
2605
+
2564
2606
  ## Document font (#1657; published in ui 0.54.0; additive, no core change)
2565
2607
 
2566
2608
  Settings → Display can show a **Document font** row beside Text size. Inert until a host opts in, like Text size.
package/README.md CHANGED
@@ -490,7 +490,7 @@ See HANDOFF.md § "Decision toggle on any question, and an opener for the host's
490
490
 
491
491
  **Answers without comments (ui 0.53.0).** `Viewer` `answerOnly`: annotation authoring and the document actions (Global comment, Copy) are off as under `readOnly`, while question cards and checkboxes keep their handlers, for a host that shows a message to answer, not a document to annotate. See HANDOFF.md § "Viewer `answerOnly` (ui 0.53.0)".
492
492
 
493
- **Comments on an answer-only document (ui 0.53.1).** `Viewer` `textComments`, with `answerOnly`: the text-selection toolbar (comment-only) and its composer come back, question cards stay out of selection, nothing else of authoring does. See HANDOFF.md § "Viewer `textComments` (ui 0.53.1)".
493
+ **Comments on an answer-only document (ui 0.53.1).** `Viewer` `textComments`, with `answerOnly`: the text-selection toolbar (comment-only) and its composer come back, a diagram's parts take comments too (ui 0.56.0), question cards stay out of selection, nothing else of authoring does. See HANDOFF.md § "Viewer `textComments` (ui 0.53.1)".
494
494
 
495
495
  **Local links decided by the host (ui 0.53.0).** `Viewer` `resolveLocalLink`: the host decides what a link to a local path does (open it itself, or draw it as inert text), markdown and raw HTML alike. See HANDOFF.md § "Viewer `resolveLocalLink` (ui 0.53.0)".
496
496
 
@@ -516,7 +516,7 @@ import { DiffFileTree } from "@plannotator/ui/components/DiffFileTree";
516
516
 
517
517
  For a custom row, use the atoms (`DiffFileTreeFolderRow`, `DiffFileTreeFileRowContent`, `ChangeTypeLetter`, `DiffCounts`, `diffFileTreeIndent`) with `buildDiffFileTree` and `useDiffFileTreeExpansion`. Plannotator's review shares the tree builder, the expansion hook and `ChangeTypeLetter`, but since its aligned sidebar it draws its own folder and file rows, so it is no longer an example of these atoms. See HANDOFF.md § "Diff file tree".
518
518
 
519
- ### Document contents rail (`components/MarkdownContentsRail`; unreleased)
519
+ ### Document contents rail (`components/MarkdownContentsRail`; ui 0.56.0)
520
520
 
521
521
  Workspaces' contents rail, moved here (approved 2026-10-03 in Workspaces; the move approved 2026-10-30) so Workspaces and the Plannotator Inbox draw the same one. Plan review does not use it.
522
522
 
@@ -548,7 +548,7 @@ Two, both in the package, never two on one surface:
548
548
  - **Plan review** keeps `hooks/useActiveSection`, unchanged: an IntersectionObserver band from 80 px below the scroll element's top to 20% of its height. `useActiveSection.positions.test.tsx` pins its answers at scroll positions that tell 80 from 52 or 100 px and 20% from 30%.
549
549
  - **The contents rail's surface** (the rail, and `DocumentContents` where the rail yields) uses the rail's scroll math, `utils/headingScrollSpy` (above); `hooks/useHeadingScrollSpy(scrollViewport, blockIds)` is it for a list. Both scroll a pick to 80 px, so a pick lands where plan review's does.
550
550
 
551
- ### Document contents (`components/DocumentContents`; unreleased)
551
+ ### Document contents (`components/DocumentContents`; ui 0.56.0)
552
552
 
553
553
  A Contents list on the contents rail's surface, for where the rail yields: `TableOfContents` bound to one scroll element, with the rail's headings (levels 1 to 4) and the rail's scroll math (`useHeadingScrollSpy`) marking the heading being read, so the list and the rail never disagree. It reads the current heading at once on mount (a sheet opened mid-document). It draws no chrome; the host titles and places it. It keeps the marked entry in view inside the list's own scroll box.
554
554
 
@@ -562,6 +562,76 @@ import { DocumentContents } from "@plannotator/ui/components/DocumentContents";
562
562
  - `TableOfContents` takes two optional seams: `scrollViewport`, the element a click scrolls (default: the `ScrollViewportProvider` context, as before), and `maxLevel` (default 3). Plan review passes neither and is unchanged.
563
563
  - Used by the Inbox's sheet (a phone, or where the rail yields) and the phone surface; plan review keeps `SidebarContainer`.
564
564
 
565
+ ### Floating chat window (`components/chat`; ui 0.56.0)
566
+
567
+ A floating window, a transcript and a composer, as screen code only: the host owns the conversation, the transport and anything saved. It imports nothing from a backend and has no seam in `configurePlannotatorUI`; everything comes in as props.
568
+
569
+ ```tsx
570
+ import { ChatComposer, ChatFrame, ChatTranscript, ChatWindowStore } from "@plannotator/ui/components/chat";
571
+
572
+ const store = new ChatWindowStore({ initialGeometry: saved, onGeometryChange: save, onOpen: load });
573
+
574
+ <div className="relative isolate">
575
+ <ChatFrame store={store} label="Chat" header={() => <span>Chat</span>} onClose={close}>
576
+ {() => (
577
+ <>
578
+ <ChatTranscript messages={messages} status={status} agentName="Agent" />
579
+ <ChatComposer value={value} onChange={setValue} onSend={send} below={<span>docs/plan.md</span>} />
580
+ </>
581
+ )}
582
+ </ChatFrame>
583
+ </div>
584
+ ```
585
+
586
+ - **Frame.** `ChatFrame` is `absolute`, so its parent is the frame it floats in and is fitted to. Give that parent its own stacking context (`isolate`): the window is `z-40`. Under 392 px of width the window is forced fullscreen, because it cannot float there.
587
+ - **Geometry.** The store holds what the person chose. What is drawn is fitted to the frame at render, so a frame that narrows for a moment changes neither the store nor what you save. `onGeometryChange` runs for each step of a person's drag or resize, so debounce it and flush on unload. It does not run for `initialGeometry`, for `restore`, or when the frame changes size. The store never touches storage.
588
+ - **Keys.** The frame listens to no key. `useChatFrameKeys` is the opt-in map (Mod+J, Shift+Mod+J, Escape). `useChatFrameFocusReturn` returns focus to the element wearing `data-chat-launcher` when focus was in the window.
589
+ - **Composer.** It is controlled and set `onChange` synchronously, or typing and IME input are lost. `onSend` runs while `busy` and with an empty box, so guard those. Enter does not bubble past the React root, so a bubble listener on `window` or `document` does not hear it; capture listeners and native listeners on ancestors still do. Other keys bubble as usual, so an app's own Escape handling still sees them. On a touch keyboard pass `sendOnEnter={false}`: it has no Shift, so otherwise no message can have a second line.
590
+ - **Text.** `ChatTranscript` draws text parts as sanitized markdown. Nothing in message text can carry a class, a `data-*` or `aria-*` attribute, an image or a script, and links open in a new tab with `noopener`. Raw HTML in an agent's text shows as text; the one exception is `<br>`, which stays a line break. Where the sanitizer fails its check on first use, or the text is past a work limit (length, parse time, the size of the HTML it makes, or thousands of characters past ASCII in one paragraph or code block, which is what makes layout slow), the message goes out as plain text. The time limit is a check, not a wall, and it is wall-clock, so a text near it can draw either way. A run of text too costly to parse, such as one holding a very long word, is local: it goes out as literal text and the rest stays formatted, and so does a table with too many cells. Long lines of code in a code block cost nothing. Mark a part `state: 'streaming'` while it grows: a text too costly to draw on every delta then stays plain, and redraws only as it grows by an eighth, so its last words can lag. It renders in full once the part stops streaming or the turn ends. A person can send while an answer streams: keep `status` at `streaming` and the answer above their message keeps streaming, and put a turn that is over in `stopped`. Rendering never throws.
591
+ - **Parts.** `renderPart(part, { message, index })` draws a part; return `undefined` to fall through, or `null` to draw nothing. To paint into a text part, return your own `ChatMarkdown`, with the props the transcript gives its own plus yours. Leave out `literalHtml` and a person's `<b>` draws bold:
592
+
593
+ ```tsx
594
+ <ChatMarkdown
595
+ text={part.text}
596
+ streaming={part.state === "streaming"}
597
+ literalHtml={message.role === "user"}
598
+ messageId={message.id}
599
+ role={message.role}
600
+ partIndex={index}
601
+ annotatable
602
+ rootRef={ref}
603
+ />
604
+ ```
605
+
606
+ `data-text-part`, `data-message-id`, `data-message-role`, `data-part-index` and `data-annotatable` on its root are written by this code alone.
607
+ - **Styles.** Nine tokens are not in `theme.css`; `components/chat/index.ts` lists them, and a host defines them in its own `@theme`. Add this folder and `components/ui/{button,textarea}.tsx` to your `@source`. Recipes live in `chatRecipes.tsx` so a `*.tsx` glob reaches them.
608
+
609
+ ### Inbox thread parts (`components/inbox-thread`; ui 0.56.0, in progress)
610
+
611
+ The Plannotator Inbox's thread display parts, moved here unchanged so the Inbox and Workspaces draw the same thread. They move in small steps, so treat these imports as unstable until the set is complete.
612
+
613
+ ```tsx
614
+ import { Icon, HostMark } from "@plannotator/ui/components/inbox-thread/icons";
615
+ import { GuideCard } from "@plannotator/ui/components/inbox-thread/GuideCard";
616
+
617
+ <div className="pn-inbox" data-pn-tooltip-root="">
618
+ <GuideCard guide={guide} onOpen={open} />
619
+ </div>
620
+ ```
621
+
622
+ ```css
623
+ /* Its own stylesheet: import it OUTSIDE any cascade layer, after the host's base reset. */
624
+ @import "@plannotator/ui/inbox-thread.css";
625
+ ```
626
+
627
+ - **In the folder so far.** `icons` (`Icon`, `HostMark`, `AuthorMark`, `TabGlyph`, `TailscaleMark`), `Tip` (`Tip`, `isTextCut`), `FileName`, `GuideCard`, `SkillsLine`, `ReaskNote`, and `reask` (the pure Re-ask rules).
628
+ - **Host-neutral.** A part takes what it shows as props, over core's `InboxMessageWire`, `InboxAttachmentState` and related types. It makes no request and reads no route, hash, cookie or storage. `tests/inbox-thread-parts-host-neutral.test.ts` fails if one does.
629
+ - **Tooltips.** `Tip` portals into the nearest `[data-pn-tooltip-root]`, so put that attribute on your `.pn-inbox` root, the same element: the sheet styles nothing outside that root. Without a `TooltipProvider`, each `Tip` keeps its own 150 ms delay.
630
+ - **Styles.** The parts draw with the Inbox's `ib-` classes, and those rules stay in `packages/inbox/inbox.css`, their only source. `@plannotator/ui/inbox-thread.css` is generated from that file at `prepack` (the pattern of `bridge-script.asset.js`; gitignored, in `files`). It is the whole sheet, about 560 KB raw and 79 KB gzipped, not a subset. What a host has to do is under "Consuming it".
631
+ - **Nothing reaches outside `.pn-inbox`.** The generator scopes every selector to the root with `:where(.pn-inbox)`, which adds no specificity, so the cascade is the Inbox's. It drops the rules that style the page itself (`html`, `body`, `#root`, `html:has(.pn-inbox)`, `:root[data-ib-keyboard-pin]:has(.pn-inbox)`, `html.ib-theme-instant`). What stays on `:root` is `--ib-*` tokens only, and every global name (`@keyframes`, `@property`, `::highlight()`, container names) starts `ib-`. The cost: a part that portals outside the root has no look, so portal into the root. `tests/inbox-thread-css.test.ts` checks all of this over the real `inbox.css`, so a new rule there cannot leak unnoticed. A new at-rule the generator does not know, such as `@font-face`, fails `prepack`.
632
+ - **Width.** The sheet carries the Inbox's viewport media queries as they are. In a panel narrower or wider than the Inbox's pane they answer for the window, not the panel. See HANDOFF.md § "Inbox thread stylesheet".
633
+ - **Plannotator.** The parts' old paths in `packages/inbox` re-export them, and the Inbox passes nothing new and does not load the generated sheet. These parts add no Tailwind output: the built CSS of plan review, code review, the Inbox and `styles.css` does not change.
634
+
565
635
  ## Consuming it (e.g. from Workspaces)
566
636
 
567
637
  ```bash
@@ -579,6 +649,13 @@ npm install @plannotator/ui @plannotator/core
579
649
  The same policy covers math: KaTeX's stylesheet + fonts are deliberately not in `styles.css` — if you render math, load `katex/dist/katex.min.css` yourself (import, CDN tag, or self-hosted copy; see HANDOFF.md "Math rendering").
580
650
  4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";`
581
651
  5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`.
652
+ 6. **If you render the Inbox thread parts** (`components/inbox-thread`):
653
+ - Import `@plannotator/ui/inbox-thread.css` **outside any cascade layer, after your base reset**, as with `contents-rail.css`. Inside a layer below Tailwind's `base`, Tailwind's preflight beats it.
654
+ - Keep `styles.css` from step 2: the sheet reads its theme tokens and defines none of them.
655
+ - Wrap the parts in one root with both markers, `<div className="pn-inbox" data-pn-tooltip-root="">`. The root sets the type, `height: 100%`, `position: relative` and `background: var(--ib-ground)`. The Inbox's pane paints `--card`, so set `background: var(--card)` on your root to match it, or use your panel's own surface. In a panel that scrolls, `height: 100%` stops the background short of tall content, so set `height: auto` on the root too, or make the root the scroller.
656
+ - Fonts are step 3's. The sheet ships none. With Tailwind, its theme defines `--font-sans` and `--font-mono`, and that beats `styles.css` in a lower layer, so the parts draw in the system font. Set both on your root.
657
+ - Set the mode on `<html>` as `ThemeProvider` does: the sheet reads `:root.light`. `styles.css` also holds every transition at 0 s until `<html>` has `.transitions-ready`, which `ThemeProvider` adds after mount.
658
+ - Portal into the root: the sheet styles nothing outside it.
582
659
 
583
660
  **Upgrade note: the plan look defaults to Clean.** `gridEnabled` (storage key `plannotator-grid-enabled`) now defaults to `false`, and a stored value counts only with the v2 marker `plannotator-plan-look-v2 = 'resolved'`. Plannotator's chooser and its Settings switch write it. On the first settings read, a v1 marker (`plannotator-plan-look-choice-resolved = 'true'`, or the legacy `plannotator-look-feel-announcement-seen = '2'`) next to a stored `'true'` is moved to Clean once (the stored value is rewritten to `'false'` and the v2 key set to `'notice'`); a v1 marker with Clean becomes `'resolved'` silently. Plannotator's apps then show a one-time notice. That includes the share portal's root and demo pages; only a shared session puts it off. A host that renders no chooser (Workspaces) shows nothing, so its users simply open in Clean, and every user with no marker opens in Clean too. To keep stored values instead, call `markLookAndFeelChoiceResolved()` from `@plannotator/ui/utils/lookAndFeelAnnouncement` before the first settings read, or seed `plannotator-plan-look-v2 = 'resolved'` in your `storageBackend`. A host that writes `gridEnabled` from its own UI should call `markLookAndFeelChoiceResolved()` with it, or the value will not survive a reload.
584
661
 
@@ -587,7 +664,7 @@ npm install @plannotator/ui @plannotator/core
587
664
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
588
665
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
589
666
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
590
- - Currently `@plannotator/ui` 0.55.0 depends exactly on `@plannotator/core` 0.25.14. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. `bun run check:sibling-deps @plannotator/ui` (from the repo root; it reads npm) fails while the pinned core isn't on npm. See HANDOFF.md "Publishing & versioning" for the verification command.
667
+ - Currently `@plannotator/ui` 0.56.0 depends exactly on `@plannotator/core` 0.25.14. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. `bun run check:sibling-deps @plannotator/ui` (from the repo root; it reads npm) fails while the pinned core isn't on npm. See HANDOFF.md "Publishing & versioning" for the verification command.
591
668
 
592
669
  ## The one rule
593
670
 
@@ -12,6 +12,7 @@ import { OverlayScrollArea } from './OverlayScrollArea';
12
12
  import { Button } from './ui/button';
13
13
  import { cn } from '../lib/utils';
14
14
  import { resolveReplyParents, resolveThreadRootTimestamps } from '@plannotator/core/annotation-threads';
15
+ import { basenameFromPath } from '@plannotator/core/source-save';
15
16
  import { ROOT_DOCUMENT_GROUP_KEY } from '../utils/annotationScope';
16
17
  import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotationScope';
17
18
  import type { QuestionPanelRow } from '../utils/questionAnswers';
@@ -1342,7 +1343,7 @@ const CodeAnnotationCard: React.FC<{
1342
1343
  const lineRange = annotation.lineStart === annotation.lineEnd
1343
1344
  ? `line ${annotation.lineStart}`
1344
1345
  : `lines ${annotation.lineStart}-${annotation.lineEnd}`;
1345
- const fileName = annotation.filePath.split('/').pop() || annotation.filePath;
1346
+ const fileName = basenameFromPath(annotation.filePath);
1346
1347
 
1347
1348
  const handleCancelEdit = () => {
1348
1349
  setIsEditing(false);
@@ -189,7 +189,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
189
189
  const trimmed = manualPath.trim();
190
190
  if (trimmed) {
191
191
  const name = deriveImageName(
192
- trimmed.split('/').pop() || 'image',
192
+ trimmed.replace(/\\/g, '/').split('/').pop() || 'image',
193
193
  images.map(i => i.name)
194
194
  );
195
195
  onAdd({ path: trimmed, name });
@@ -1,6 +1,8 @@
1
1
  import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
2
2
  import { File, type LineAnnotation } from '@pierre/diffs/react';
3
3
  import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs';
4
+ import { normalizeBrowserPath } from '@plannotator/core/browser-paths';
5
+ import { basenameFromPath } from '@plannotator/core/source-save';
4
6
  import { PopoutDialog } from './PopoutDialog';
5
7
  import { isCommentSubmitKey } from '../utils/commentSubmitKey';
6
8
  import { useConfigValue } from '../config/useConfig';
@@ -399,8 +401,8 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
399
401
  setPendingComment(null);
400
402
  }, [filepath]);
401
403
 
402
- const displayName = filepath.split('/').pop() || filepath;
403
- const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
404
+ const displayName = basenameFromPath(filepath);
405
+ const relativePath = normalizeBrowserPath(filepath).replace(/.*\/(?=.*\/)/, '');
404
406
  const selectedCodeAnnotation = useMemo(
405
407
  () => annotations.find((ann) => ann.id === selectedAnnotationId),
406
408
  [annotations, selectedAnnotationId],
@@ -696,8 +698,8 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
696
698
  }) => {
697
699
  const [copied, setCopied] = useState(false);
698
700
 
699
- const displayName = filepath.split('/').pop() || filepath;
700
- const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
701
+ const displayName = basenameFromPath(filepath);
702
+ const relativePath = normalizeBrowserPath(filepath).replace(/.*\/(?=.*\/)/, '');
701
703
  const lineCount = useMemo(() => contents.split('\n').length, [contents]);
702
704
 
703
705
  const handleCopy = async () => {
@@ -207,6 +207,16 @@ export interface ComposerTokenTextareaProps {
207
207
  React.TextareaHTMLAttributes<HTMLTextAreaElement>,
208
208
  'value' | 'onChange' | 'onKeyDown' | 'onSelect' | 'onScroll' | 'className' | 'placeholder'
209
209
  >;
210
+ /** Paint on the mirror that is not in the value. Same metric rule as the token spans: it must add no width. */
211
+ mirrorPaint?: ComposerMirrorPaint;
212
+ }
213
+
214
+ export interface ComposerMirrorPaint {
215
+ /** Drawn at this offset (past the end of a token it falls inside). */
216
+ at?: number;
217
+ node?: React.ReactNode;
218
+ /** A range of plain text behind a mark. A token inside it keeps its own paint. */
219
+ wash?: { start: number; end: number; className: string };
210
220
  }
211
221
 
212
222
  /**
@@ -241,6 +251,7 @@ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
241
251
  inputClassName,
242
252
  mirrorClassName,
243
253
  inputProps,
254
+ mirrorPaint,
244
255
  }) => {
245
256
  const overlayRef = useRef<HTMLDivElement>(null);
246
257
  const [composing, setComposing] = useState(false);
@@ -325,15 +336,45 @@ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
325
336
  }
326
337
 
327
338
  const segments: React.ReactNode[] = [];
339
+ let paintDrawn = false;
340
+ /** One run of plain text, cut at the paint's offsets. With no paint, the plain string as ever. */
341
+ const pushPlain = (start: number, end: number) => {
342
+ if (!mirrorPaint) {
343
+ segments.push(value.slice(start, end));
344
+ return;
345
+ }
346
+ const { at, node, wash } = mirrorPaint;
347
+ const cuts = [start, end, ...[at, wash?.start, wash?.end].filter((cut): cut is number => cut !== undefined && cut > start && cut < end)].sort((a, b) => a - b);
348
+ for (let i = 0; i + 1 < cuts.length; i++) {
349
+ const from = cuts[i]!;
350
+ const to = cuts[i + 1]!;
351
+ if (!paintDrawn && at !== undefined && from >= at) {
352
+ paintDrawn = true;
353
+ segments.push(<React.Fragment key="mirror-paint">{node}</React.Fragment>);
354
+ }
355
+ if (from === to) continue;
356
+ const text = value.slice(from, to);
357
+ segments.push(
358
+ wash && from >= wash.start && to <= wash.end ? (
359
+ <mark key={`mirror-wash-${from}`} className={wash.className}>
360
+ {text}
361
+ </mark>
362
+ ) : (
363
+ text
364
+ ),
365
+ );
366
+ }
367
+ };
328
368
  let pos = 0;
329
369
  for (const range of ranges) {
330
- if (range.start > pos) segments.push(value.slice(pos, range.start));
370
+ if (range.start > pos) pushPlain(pos, range.start);
331
371
  segments.push(
332
372
  renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
333
373
  );
334
374
  pos = range.end;
335
375
  }
336
- segments.push(value.slice(pos));
376
+ pushPlain(pos, value.length);
377
+ if (mirrorPaint?.at !== undefined && !paintDrawn) segments.push(<React.Fragment key="mirror-paint">{mirrorPaint.node}</React.Fragment>);
337
378
 
338
379
  return (
339
380
  <div className="relative">
@@ -19,7 +19,9 @@ import React from 'react';
19
19
  import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
20
20
  import type { PlanDiffStats } from '../utils/planDiffEngine';
21
21
  import { hostnameOrFallback } from '@plannotator/core/project';
22
+ import { basenameFromPath } from '@plannotator/core/source-save';
22
23
  import { OpenInAppButton } from './OpenInAppButton';
24
+ import { HostTitle } from './HostTitle';
23
25
 
24
26
  export interface LinkedDocBadgeInfo {
25
27
  filepath: string;
@@ -74,8 +76,11 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
74
76
  const isHorizontal = layout !== 'column';
75
77
  const canOpenInApp =
76
78
  !!openInAppPath && !/^https?:\/\//i.test(openInAppPath);
79
+ // Never shrinks: a file name chip beside it gives up its width first.
77
80
  const openInButton = canOpenInApp ? (
78
- <OpenInAppButton filePath={openInAppPath} base={null} />
81
+ <span className="flex flex-shrink-0">
82
+ <OpenInAppButton filePath={openInAppPath} base={null} />
83
+ </span>
79
84
  ) : null;
80
85
 
81
86
  // In row layout, only PlanDiffBadge (when it has stats to show) and
@@ -100,24 +105,32 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
100
105
  under the action buttons. The Viewer-owned header measures and wraps
101
106
  the complete badge set, so its `header` layout keeps this context. */}
102
107
  {repoInfo && !linkedDocInfo && !isRow && (
103
- <div className="flex items-center gap-1.5">
104
- <span
105
- className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]"
106
- title={repoInfo.display}
107
- >
108
- {repoInfo.display}
109
- </span>
110
- {repoInfo.branch && (
111
- <span
112
- className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden"
113
- title={repoInfo.branch}
114
- >
115
- <svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
116
- <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
117
- </svg>
118
- <span className="truncate">{repoInfo.branch}</span>
119
- </span>
120
- )}
108
+ <div className="flex min-w-0 max-w-full items-center gap-1.5">
109
+ {/* No fixed caps: the chips take the room the cluster has. When it runs
110
+ out, the branch gives way first, down to its icon and a letter or
111
+ two, and only then the repository: the repository never shrinks,
112
+ but stops short of the branch's floor (4.5em) and the gap. */}
113
+ <div className="flex min-w-0 items-center gap-1.5">
114
+ <HostTitle title={repoInfo.display} cut>
115
+ <span
116
+ className={`truncate px-1.5 py-0.5 bg-muted/50 rounded ${
117
+ repoInfo.branch ? 'flex-shrink-0 max-w-[calc(100%_-_4.5em_-_0.375rem)]' : 'min-w-0'
118
+ }`}
119
+ >
120
+ {repoInfo.display}
121
+ </span>
122
+ </HostTitle>
123
+ {repoInfo.branch && (
124
+ <HostTitle title={repoInfo.branch} cut>
125
+ <span className="min-w-[4.5em] px-1.5 py-0.5 bg-muted/30 rounded flex items-center gap-1 overflow-hidden">
126
+ <svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
127
+ <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
128
+ </svg>
129
+ <span className="truncate">{repoInfo.branch}</span>
130
+ </span>
131
+ </HostTitle>
132
+ )}
133
+ </div>
121
134
  {/* Markdown/text annotate sessions have no separate source badge, so
122
135
  keep the open-in control attached to the repository context. */}
123
136
  {canOpenInApp && !sourceInfo && openInButton}
@@ -199,7 +212,7 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
199
212
  {/* Linked-doc breadcrumb: only in column layout (sticky lane is hidden in linked-doc mode) */}
200
213
  {!isRow && linkedDocInfo && (
201
214
  linkedDocInfo.variant === 'folder-file' ? (
202
- <div className="flex items-center gap-2">
215
+ <div className="flex min-w-0 max-w-full items-center gap-2">
203
216
  {linkedDocInfo.onBack && (
204
217
  <button
205
218
  type="button"
@@ -209,19 +222,19 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
209
222
  Close
210
223
  </button>
211
224
  )}
212
- <span
213
- className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground max-w-[220px]"
214
- title={linkedDocInfo.filepath}
215
- >
216
- {linkedDocInfo.filepath.split('/').pop()}
217
- </span>
225
+ {/* The hover text is the whole path, so it shows whether or not the name is cut. */}
226
+ <HostTitle title={linkedDocInfo.filepath}>
227
+ <span className="min-w-0 truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground">
228
+ {basenameFromPath(linkedDocInfo.filepath)}
229
+ </span>
230
+ </HostTitle>
218
231
  {openInButton}
219
232
  </div>
220
233
  ) : (
221
- <div className="flex items-center gap-1.5">
234
+ <div className="flex min-w-0 max-w-full items-center gap-1.5">
222
235
  <button
223
236
  onClick={linkedDocInfo.onBack}
224
- className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex items-center gap-1"
237
+ className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex flex-shrink-0 items-center gap-1"
225
238
  >
226
239
  <svg
227
240
  className="w-2.5 h-2.5"
@@ -238,15 +251,14 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
238
251
  </svg>
239
252
  {linkedDocInfo.backLabel || 'plan'}
240
253
  </button>
241
- <span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
254
+ <span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded flex-shrink-0">
242
255
  {linkedDocInfo.label || 'Linked File'}
243
256
  </span>
244
- <span
245
- className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
246
- title={linkedDocInfo.filepath}
247
- >
248
- {linkedDocInfo.filepath.split('/').pop()}
249
- </span>
257
+ <HostTitle title={linkedDocInfo.filepath}>
258
+ <span className="min-w-0 px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate">
259
+ {basenameFromPath(linkedDocInfo.filepath)}
260
+ </span>
261
+ </HostTitle>
250
262
  {openInButton}
251
263
  </div>
252
264
  )
@@ -23,6 +23,7 @@ import {
23
23
  zoomLimits,
24
24
  type FitAxis,
25
25
  type NaturalBox,
26
+ type ZoomClear,
26
27
  type ZoomInset,
27
28
  type ZoomSize,
28
29
  type ZoomViewport,
@@ -165,7 +166,11 @@ function prefersReducedMotion(): boolean {
165
166
  * (Fit fills the width or height, the smallest scale stays the whole
166
167
  * image), `padding` (Fit leaves that many pixels on every side), `overlay`
167
168
  * (drawn over the image in the same transform, e.g. a marked-up copy, so it
168
- * stays aligned at any zoom), and `onImageLoad` / `onImageError`.
169
+ * stays aligned at any zoom), `clear` (rows the host floats things over: Fit
170
+ * and the pan's limits work between them, the image still slides under them
171
+ * when zoomed, and those rows still take the fingers), and `onImageLoad` /
172
+ * `onImageError`. `clear` and Region mode do not go together: `children` and
173
+ * `onViewChange` speak of the stage without it.
169
174
  *
170
175
  * Region comments (#2100) add three more, also optional: `drawing` (a
171
176
  * primary press and drag draws a box, reported through `onDraw` in the
@@ -188,6 +193,8 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
188
193
  padding?: number;
189
194
  /** Which way Fit fits (default: the whole image). */
190
195
  fitAxis?: FitAxis;
196
+ /** Rows at the stage's top and bottom that the host floats things over (default: none). */
197
+ clear?: ZoomClear;
191
198
  /** Drawn over the image at its own size, in the image's transform. */
192
199
  overlay?: React.ReactNode;
193
200
  /** The image's own size, once it has loaded. */
@@ -216,7 +223,7 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
216
223
  children?: React.ReactNode;
217
224
  /** The "⌘ + scroll to zoom" hint's gate, shared across the viewer's stages; `false` turns the hint off. Absent: once per stage. */
218
225
  wheelHint?: WheelHintGate | false;
219
- }>(function ImageZoomStage({ src, alt, mode, onModeChange, onScaleChange, inset, padding, fitAxis, overlay, onImageLoad, onImageError, onBackgroundTap, className, imageClassName, radius = 6, stageAttributes, label, description, drawing = false, onDraw, onViewChange, children, wheelHint }, ref) {
226
+ }>(function ImageZoomStage({ src, alt, mode, onModeChange, onScaleChange, inset, padding, fitAxis, clear, overlay, onImageLoad, onImageError, onBackgroundTap, className, imageClassName, radius = 6, stageAttributes, label, description, drawing = false, onDraw, onViewChange, children, wheelHint }, ref) {
220
227
  const stageRef = useRef<HTMLDivElement>(null);
221
228
  const imgRef = useRef<HTMLImageElement>(null);
222
229
  const [dims, setDims] = useState<ZoomSize | null>(null);
@@ -230,6 +237,10 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
230
237
  paddingRef.current = padding;
231
238
  const axisRef = useRef<FitAxis>(fitAxis ?? 'contain');
232
239
  axisRef.current = fitAxis ?? 'contain';
240
+ const clearTop = clear?.top ?? 0;
241
+ const clearBottom = clear?.bottom ?? 0;
242
+ const clearRef = useRef({ top: clearTop, bottom: clearBottom });
243
+ clearRef.current = { top: clearTop, bottom: clearBottom };
233
244
  const overlayRef = useRef<HTMLDivElement | null>(null);
234
245
  const dimsRef = useRef<ZoomSize | null>(null);
235
246
  const boxRef = useRef<StageBox | null>(null);
@@ -291,7 +302,9 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
291
302
  const stage = stageRef.current;
292
303
  if (!stage) return null;
293
304
  const r = stage.getBoundingClientRect();
294
- boxRef.current = { width: r.width, height: r.height, left: r.left, top: r.top };
305
+ // The box every viewport is worked out in: the stage between the clear rows, its corner where the viewport's 0, 0 is drawn (`paint`).
306
+ const { top, bottom } = clearRef.current;
307
+ boxRef.current = { width: r.width, height: r.height - top - bottom, left: r.left, top: r.top + top };
295
308
  return boxRef.current;
296
309
  }, []);
297
310
 
@@ -304,7 +317,7 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
304
317
  const paint = useCallback(
305
318
  (el: HTMLElement, view: ZoomViewport, animate: boolean) => {
306
319
  el.style.transition = animate && !prefersReducedMotion() ? 'transform 180ms cubic-bezier(0.2, 0, 0, 1)' : 'none';
307
- el.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
320
+ el.style.transform = `translate(${view.x}px, ${view.y + clearRef.current.top}px) scale(${view.scale})`;
308
321
  el.style.borderRadius = `${radius / view.scale}px`;
309
322
  },
310
323
  [radius],
@@ -376,6 +389,21 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
376
389
  // eslint-disable-next-line react-hooks/exhaustive-deps
377
390
  }, [padding, fitAxis, measure, applyMode, apply]);
378
391
 
392
+ // The host's clear rows changed (what floats came or went): Fit eases to the new space, anything else stays where it is on screen, within it.
393
+ const clearShape = useRef({ top: clearTop, bottom: clearBottom });
394
+ useLayoutEffect(() => {
395
+ const movedBy = clearShape.current.top - clearTop;
396
+ if (movedBy === 0 && clearShape.current.bottom === clearBottom) return;
397
+ clearShape.current = { top: clearTop, bottom: clearBottom };
398
+ measure();
399
+ if (!ready() || !viewRef.current) return;
400
+ if (modeRef.current === 'fit') return applyMode('fit', true);
401
+ const kept = clampViewport({ ...viewRef.current, y: viewRef.current.y + movedBy }, dimsRef.current!, boxRef.current!, limits());
402
+ if (modeRef.current === 'zoom') gesture(kept);
403
+ else apply(kept);
404
+ // eslint-disable-next-line react-hooks/exhaustive-deps
405
+ }, [clearTop, clearBottom, measure, applyMode, apply]);
406
+
379
407
  // The image's own size, once it has loaded (or at once when it was cached).
380
408
  useEffect(() => {
381
409
  const img = imgRef.current;
@@ -7,6 +7,7 @@ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal
7
7
  import type { Origin } from '@plannotator/core/agents';
8
8
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
9
9
  import type { TokenHoverDelay } from '@plannotator/core/token-hover';
10
+ import { basenameFromPath } from '@plannotator/core/source-save';
10
11
  import {
11
12
  configStore,
12
13
  useConfigValue,
@@ -2670,7 +2671,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2670
2671
  >
2671
2672
  {detectedVaults.map((vault) => (
2672
2673
  <option key={vault} value={vault}>
2673
- {vault.split('/').pop() || vault}
2674
+ {basenameFromPath(vault)}
2674
2675
  </option>
2675
2676
  ))}
2676
2677
  <option value={CUSTOM_PATH_SENTINEL}>Custom path...</option>