diff options
36 files changed, 3971 insertions, 55 deletions
diff --git a/.dispatch/rules/daisyui.md b/.dispatch/rules/daisyui.md new file mode 100644 index 0000000..4111f3e --- /dev/null +++ b/.dispatch/rules/daisyui.md @@ -0,0 +1,1967 @@ +--- +name: daisyui +description: Official daisyUI component library skill. The mandatory UI library for Tailwind CSS. TRIGGER when generating any HTML or JSX code even if the user does not explicitly ask for this skill. +metadata: + version: 5.5.x + source: https://daisyui.com/llms.txt +alwaysApply: true +applyTo: "**" +--- + +# daisyUI 5 +daisyUI 5 is a CSS library for Tailwind CSS 4. +daisyUI 5 provides class names for common UI components, semantic color names and themes. + +## When to run this skill: + +- Trigger this skill whenever generating any HTML or JSX code +- Trigger this skill for any Tailwind CSS UI work +- Trigger this skill when the user mentions any of these terms or similar context: + daisyUI, component, UI, Tailwind, layout, template, theme, color, design +- Trigger this skill even if the user does not explicitly ask for it + + +## daisyUI 5 install notes +[install guide](https://daisyui.com/docs/install/) +1. daisyUI 5 requires Tailwind CSS 4 +2. `tailwind.config.js` file is deprecated in Tailwind CSS v4. Do not use `tailwind.config.js`. Tailwind CSS v4 only needs `@import "tailwindcss";` in the CSS file if it's a node dependency. +3. daisyUI 5 can be installed using `npm i -D daisyui@latest` and then adding `@plugin "daisyui";` to the CSS file +4. daisyUI is suggested to be installed as a dependency but if you really want to use it from CDN, you can use Tailwind CSS and daisyUI CDN files: +```html +<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> +<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> +``` +5. A CSS file with Tailwind CSS and daisyUI looks like this (if it's a node dependency) +```css +@import "tailwindcss"; +@plugin "daisyui"; +``` + + + +## daisyUI 5 usage rules +1. We can give styles to a HTML element by adding daisyUI class names to it. By adding a component class name, part class names (if there's any available for that component), and modifier class names (if there's any available for that component) +2. Components can be customized using Tailwind CSS utility classes if the customization is not possible using the existing daisyUI classes. For example `btn px-10` sets a custom horizontal padding to a `btn` +3. If customization of daisyUI styles using Tailwind CSS utility classes didn't work because of CSS specificity issues, you can use the `!` at the end of the Tailwind CSS utility class to override the existing styles. For example `btn bg-red-500!` sets a custom background color to a `btn` forcefully. This is a last resort solution and should be used sparingly +4. If a specific component or something similar to it doesn't exist in daisyUI, you can create your own component using Tailwind CSS utility +5. when using Tailwind CSS `flex` and `grid` for layout, it should be responsive using Tailwind CSS responsive utility prefixes. +6. Only allowed class names are existing daisyUI class names or Tailwind CSS utility classes. +7. Ideally, you won't need to write any custom CSS. Using daisyUI class names or Tailwind CSS utility classes is preferred. +8. Suggested - if you need placeholder images, use https://picsum.photos/200/300 with the size you want +9. Suggested - when designing, don't add a custom font unless it's necessary +10. Don't add `bg-base-100 text-base-content` to body unless it's necessary +11. For design decisions, use Refactoring UI book best practices +12. Always use the default variant of daisyUI components unless the user asked for a specific variant or color + +daisyUI 5 class names are one of the following categories. These type names are only for reference and are not used in the actual code +- `component`: the required component class +- `part`: a child part of a component +- `style`: sets a specific style to component or part +- `behavior`: changes the behavior of component or part +- `color`: sets a specific color to component or part +- `size`: sets a specific size to component or part +- `placement`: sets a specific placement to component or part +- `direction`: sets a specific direction to component or part +- `modifier`: modifies the component or part in a specific way +- `variant`: prefixes for utility classes that conditionally apply styles. syntax is `variant:utility-class` + + + +## Config +daisyUI 5 config docs: https://daisyui.com/docs/config/ +daisyUI without config: +```css +@plugin "daisyui"; +``` +daisyUI config with `light` theme only: +```css +@plugin "daisyui" { + themes: light --default; +} +``` +daisyUI with all the default configs: +```css +@plugin "daisyui" { + themes: light --default, dark --prefersdark; + root: ":root"; + include: ; + exclude: ; + prefix: ; + logs: true; +} +``` +An example config: +In below config, all the built-in themes are enabled while bumblebee is the default theme and synthwave is the prefersdark theme (default dark mode) +All the other themes are enabled and can be used by adding `data-theme="THEME_NAME"` to the `<html>` element +root scrollbar gutter is excluded. `daisy-` prefix is used for all daisyUI classes and console.log is disabled +```css +@plugin "daisyui" { + themes: light, dark, cupcake, bumblebee --default, emerald, corporate, synthwave --prefersdark, retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, fantasy, wireframe, black, luxury, dracula, cmyk, autumn, business, acid, lemonade, night, coffee, winter, dim, nord, sunset, caramellatte, abyss, silk; + root: ":root"; + include: ; + exclude: rootscrollgutter, checkbox; + prefix: daisy-; + logs: false; +} +``` + + + +## daisyUI 5 colors + +### daisyUI color names +- `primary`: Primary brand color, The main color of your brand +- `primary-content`: Foreground content color to use on primary color +- `secondary`: Secondary brand color, The optional, secondary color of your brand +- `secondary-content`: Foreground content color to use on secondary color +- `accent`: Accent brand color, The optional, accent color of your brand +- `accent-content`: Foreground content color to use on accent color +- `neutral`: Neutral dark color, For not-saturated parts of UI +- `neutral-content`: Foreground content color to use on neutral color +- `base-100`:-100 Base surface color of page, used for blank backgrounds +- `base-200`:-200 Base color, darker shade, to create elevations +- `base-300`:-300 Base color, even more darker shade, to create elevations +- `base-content`: Foreground content color to use on base color +- `info`: Info color, For informative/helpful messages +- `info-content`: Foreground content color to use on info color +- `success`: Success color, For success/safe messages +- `success-content`: Foreground content color to use on success color +- `warning`: Warning color, For warning/caution messages +- `warning-content`: Foreground content color to use on warning color +- `error`: Error color, For error/danger/destructive messages +- `error-content`: Foreground content color to use on error color + +### daisyUI color rules +1. daisyUI adds semantic color names to Tailwind CSS colors +2. daisyUI color names can be used in utility classes, like other Tailwind CSS color names. For example, `bg-primary` will use the primary color for the background +3. daisyUI color names include variables as value so they can change based on the theme +4. There's no need to use `dark:` for daisyUI color names +5. Ideally only daisyUI color names should be used for colors so the colors can change automatically based on the theme +6. If a Tailwind CSS color name (like `red-500`) is used, it will be the same red color on all themes +7. If a daisyUI color name (like `primary`) is used, it will change color based on the theme +8. Using Tailwind CSS color names for text colors should be avoided because Tailwind CSS color `text-gray-800` on `bg-base-100` would be unreadable on a dark theme - because on dark theme, `bg-base-100` is a dark color +9. `*-content` colors should have a good contrast compared to their associated colors +10. Use `base-*` colors for majority of the page. Use the default variant for all elements. Use `primary` color once only, for the most important element on the page. + +### daisyUI custom theme with custom colors +A CSS file with Tailwind CSS, daisyUI and a custom daisyUI theme looks like this: +```css +@import "tailwindcss"; +@plugin "daisyui"; +@plugin "daisyui/theme" { + name: "mytheme"; + default: true; /* set as default */ + prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ + color-scheme: light; /* color of browser-provided UI */ + + --color-base-100: oklch(98% 0.02 240); + --color-base-200: oklch(95% 0.03 240); + --color-base-300: oklch(92% 0.04 240); + --color-base-content: oklch(20% 0.05 240); + --color-primary: oklch(55% 0.3 240); + --color-primary-content: oklch(98% 0.01 240); + --color-secondary: oklch(70% 0.25 200); + --color-secondary-content: oklch(98% 0.01 200); + --color-accent: oklch(65% 0.25 160); + --color-accent-content: oklch(98% 0.01 160); + --color-neutral: oklch(50% 0.05 240); + --color-neutral-content: oklch(98% 0.01 240); + --color-info: oklch(70% 0.2 220); + --color-info-content: oklch(98% 0.01 220); + --color-success: oklch(65% 0.25 140); + --color-success-content: oklch(98% 0.01 140); + --color-warning: oklch(80% 0.25 80); + --color-warning-content: oklch(20% 0.05 80); + --color-error: oklch(65% 0.3 30); + --color-error-content: oklch(98% 0.01 30); + + --radius-selector: 1rem; /* border radius of selectors (checkbox, toggle, badge) */ + --radius-field: 0.25rem; /* border radius of fields (button, input, select, tab) */ + --radius-box: 0.5rem; /* border radius of boxes (card, modal, alert) */ + /* preferred values for --radius-* : 0rem, 0.25rem, 0.5rem, 1rem, 2rem */ + + --size-selector: 0.25rem; /* base size of selectors (checkbox, toggle, badge). Value must be 0.25rem unless we intentionally want bigger selectors. In so it can be 0.28125 or 0.3125. If we intentionally want smaller selectors, it can be 0.21875 or 0.1875 */ + --size-field: 0.25rem; /* base size of fields (button, input, select, tab). Value must be 0.25rem unless we intentionally want bigger fields. In so it can be 0.28125 or 0.3125. If we intentionally want smaller fields, it can be 0.21875 or 0.1875 */ + + --border: 1px; /* border size. Value must be 1px unless we intentionally want thicker borders. In so it can be 1.5px or 2px. If we intentionally want thinner borders, it can be 0.5px */ + + --depth: 1; /* only 0 or 1 β Adds a shadow and subtle 3D depth effect to components */ + --noise: 0; /* only 0 or 1 - Adds a subtle noise (grain) effect to components */ +} +``` +#### Rules +- All CSS variables above are required +- Colors can be OKLCH or hex or other formats +- If you're generating a custom theme, do not include the comments from the example above. Just provide the code. + +People can use https://daisyui.com/theme-generator/ visual tool to create their own theme. + + +### Component discovery protocol + +Before writing any daisyUI code, do this in order: + +1. Read the request intent, behavior, and shape, not only literal words. Match on meaning. +2. Use the component list in this file to shortlist the best candidate components. +3. Read multiple candidate component docs before deciding. Minimum is 3 candidates when there is ambiguity. +4. Compare each candidate's description, behavior, syntax, and rules against the request. +5. Select the best component or combination of components and apply their constraints exactly. +6. State which components were chosen and why they match the request. + +Semantic matching is required even when wording differs from component names. A component name might be different from the request but still be the best match. Always consider intent and meaning, not only literal words. + +If a user explicitly requests a named component and a same-named doc exists, read that component doc first. + +## daisyUI components + +### accordion +Accordion is used for showing and hiding content but only one item can stay open at a time + +[accordion docs](https://daisyui.com/components/accordion/) + +#### Class names +- component: `collapse` +- part: `collapse-title`, `collapse-content` +- modifier: `collapse-arrow`, `collapse-plus`, `collapse-open`, `collapse-close` + +#### Syntax +```html +<div class="collapse {MODIFIER}">{CONTENT}</div> +``` +where content is: +```html +<input type="radio" name="{name}" checked="{checked}" /> +<div class="collapse-title">{title}</div> +<div class="collapse-content">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names +- Accordion uses radio inputs. All radio inputs with the same name work together and only one of them can be open at a time +- If you have more than one set of accordion items on a page, use different names for the radio inputs on each set +- Replace {name} with a unique name for the accordion group +- replace `{checked}` with `checked="checked"` if you want the accordion to be open by default + + +### alert +Alert informs users about important events + +[alert docs](https://daisyui.com/components/alert/) + +#### Class names +- component: `alert` +- style: `alert-outline`, `alert-dash`, `alert-soft` +- color: `alert-info`, `alert-success`, `alert-warning`, `alert-error` +- direction: `alert-vertical`, `alert-horizontal` + +#### Syntax +```html +<div role="alert" class="alert {MODIFIER}">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/direction class names +- Add `sm:alert-horizontal` for responsive layouts + + +### avatar +Avatars are used to show a thumbnail + +[avatar docs](https://daisyui.com/components/avatar/) + +#### Class names +- component: `avatar`, `avatar-group` +- modifier: `avatar-online`, `avatar-offline`, `avatar-placeholder` + +#### Syntax +```html +<div class="avatar {MODIFIER}"> + <div> + <img src="{image-url}" /> + </div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names +- Use `avatar-group` for containing multiple avatars +- You can set custom sizes using `w-*` and `h-*` +- You can use mask classes such as `mask-squircle`, `mask-hexagon`, `mask-triangle` + + +### badge +Badges are used to inform the user of the status of specific data + +[badge docs](https://daisyui.com/components/badge/) + +#### Class names +- component: `badge` +- style: `badge-outline`, `badge-dash`, `badge-soft`, `badge-ghost` +- color: `badge-neutral`, `badge-primary`, `badge-secondary`, `badge-accent`, `badge-info`, `badge-success`, `badge-warning`, `badge-error` +- size: `badge-xs`, `badge-sm`, `badge-md`, `badge-lg`, `badge-xl` + +#### Syntax +```html +<span class="badge {MODIFIER}">Badge</span> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names +- Can be used inside text or buttons +- To create an empty badge, just remove the text between the span tags + + +### breadcrumbs +Breadcrumbs helps users to navigate + +[breadcrumbs docs](https://daisyui.com/components/breadcrumbs/) + +#### Class names +- component: `breadcrumbs` + +#### Syntax +```html +<div class="breadcrumbs"> + <ul><li><a>Link</a></li></ul> +</div> +``` + +#### Rules +- breadcrumbs only has one main class name +- Can contain icons inside the links +- If you set `max-width` or the list gets larger than the container it will scroll + + +### button +Buttons allow the user to take actions + +[button docs](https://daisyui.com/components/button/) + +#### Class names +- component: `btn` +- color: `btn-neutral`, `btn-primary`, `btn-secondary`, `btn-accent`, `btn-info`, `btn-success`, `btn-warning`, `btn-error` +- style: `btn-outline`, `btn-dash`, `btn-soft`, `btn-ghost`, `btn-link` +- behavior: `btn-active`, `btn-disabled` +- size: `btn-xs`, `btn-sm`, `btn-md`, `btn-lg`, `btn-xl` +- modifier: `btn-wide`, `btn-block`, `btn-square`, `btn-circle` + +#### Syntax +```html +<button class="btn {MODIFIER}">Button</button> +``` +#### Rules +- {MODIFIER} is optional and can have one of each color/style/behavior/size/modifier class names +- btn can be used on any html tags such as `<button>`, `<a>`, `<input>` +- btn can have an icon before or after the text +- set `tabindex="-1" role="button" aria-disabled="true"` if you want to disable the button using a class name + + +### calendar +Calendar includes styles for different calendar libraries + +[calendar docs](https://daisyui.com/components/calendar/) + +#### Class names +- component + - `cally (for Cally web component)` + - `pika-single (for the input field that opens Pikaday calendar)` + - `react-day-picker (for the DayPicker component)` + +#### Syntax +For Cally: +```html +<calendar-date class="cally">{CONTENT}</calendar-date> +``` +For Pikaday: +```html +<input type="text" class="input pika-single"> +``` +For React Day Picker: +```html +<DayPicker className="react-day-picker"> +``` + +#### Rules +- daisyUI supports Cally, Pikaday, React Day Picker + + +### card +Cards are used to group and display content + +[card docs](https://daisyui.com/components/card/) + +#### Class names +- component: `card` +- part: `card-title`, `card-body`, `card-actions` +- style: `card-border`, `card-dash` +- modifier: `card-side`, `image-full` +- size: `card-xs`, `card-sm`, `card-md`, `card-lg`, `card-xl` + +#### Syntax +```html +<div class="card {MODIFIER}"> + <figure><img src="{image-url}" alt="{alt-text}" /></figure> + <div class="card-body"> + <h2 class="card-title">{title}</h2> + <p>{CONTENT}</p> + <div class="card-actions">{actions}</div> + </div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names and one of the size class names +- `<figure>` and `<div class="card-body">` are optional +- can use `sm:card-horizontal` for responsive layouts +- If image is placed after `card-body`, the image will be placed at the bottom + + +### carousel +Carousel show images or content in a scrollable area + +[carousel docs](https://daisyui.com/components/carousel/) + +#### Class names +- component: `carousel` +- part: `carousel-item` +- modifier: `carousel-start`, `carousel-center`, `carousel-end` +- direction: `carousel-horizontal`, `carousel-vertical` + +#### Syntax +```html +<div class="carousel {MODIFIER}">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/direction class names +- Content is a list of `carousel-item` divs: `<div class="carousel-item"></div>` +- To create a full-width carousel, add `w-full` to each carousel item + + +### chat +Chat bubbles are used to show one line of conversation and all its data, including the author image, author name, time, etc + +[chat docs](https://daisyui.com/components/chat/) + +#### Class names +- component: `chat` +- part: `chat-image`, `chat-header`, `chat-footer`, `chat-bubble` +- placement: `chat-start`, `chat-end` +- color: `chat-bubble-neutral`, `chat-bubble-primary`, `chat-bubble-secondary`, `chat-bubble-accent`, `chat-bubble-info`, `chat-bubble-success`, `chat-bubble-warning`, `chat-bubble-error` + +#### Syntax +```html +<div class="chat {PLACEMENT}"> + <div class="chat-image"></div> + <div class="chat-header"></div> + <div class="chat-bubble {COLOR}">Message text</div> + <div class="chat-footer"></div> +</div> +``` + +#### Rules +- {PLACEMENT} is required and must be either `chat-start` or `chat-end` +- {COLOR} is optional and can have one of the color class names +- To add an avatar, use `<div class="chat-image avatar">` and nest the avatar content inside + + +### checkbox +Checkboxes are used to select or deselect a value + +[checkbox docs](https://daisyui.com/components/checkbox/) + +#### Class names +- component: `checkbox` +- color: `checkbox-primary`, `checkbox-secondary`, `checkbox-accent`, `checkbox-neutral`, `checkbox-success`, `checkbox-warning`, `checkbox-info`, `checkbox-error` +- size: `checkbox-xs`, `checkbox-sm`, `checkbox-md`, `checkbox-lg`, `checkbox-xl` + +#### Syntax +```html +<input type="checkbox" class="checkbox {MODIFIER}" /> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each color/size class names + + +### collapse +Collapse is used for showing and hiding content + +[collapse docs](https://daisyui.com/components/collapse/) + +#### Class names +- component: `collapse` +- part: `collapse-title`, `collapse-content` +- modifier: `collapse-arrow`, `collapse-plus`, `collapse-open`, `collapse-close` + +#### Syntax +```html +<div tabindex="0" class="collapse {MODIFIER}"> + <div class="collapse-title">{title}</div> + <div class="collapse-content">{CONTENT}</div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names +- instead of `tabindex="0"`, you can use `<input type="checkbox">` as a first child +- Can also be a details/summary tag + + +### countdown +Countdown gives you a transition effect when you change a number between 0 to 999 + +[countdown docs](https://daisyui.com/components/countdown/) + +#### Class names +- component: `countdown` + +#### Syntax +```html +<span class="countdown"> + <span style="--value:{number};">number</span> +</span> +``` + +#### Rules +- The `--value` CSS variable and text must be a number between 0 and 999 +- you need to change the span text and the `--value` CSS variable using JS +- you need to add `aria-live="polite"` and `aria-label="{number}"` so screen readers can properly read changes + + +### diff +Diff component shows a side-by-side comparison of two items + +[diff docs](https://daisyui.com/components/diff/) + +#### Class names +- component: `diff` +- part: `diff-item-1`, `diff-item-2`, `diff-resizer` + +#### Syntax +```html +<figure class="diff"> + <div class="diff-item-1">{item1}</div> + <div class="diff-item-2">{item2}</div> + <div class="diff-resizer"></div> +</figure> +``` + +#### Rules +- To maintain aspect ratio, add `aspect-16/9` or other aspect ratio classes to `<figure class="diff">` element + + +### divider +Divider will be used to separate content vertically or horizontally + +[divider docs](https://daisyui.com/components/divider/) + +#### Class names +- component: `divider` +- color: `divider-neutral`, `divider-primary`, `divider-secondary`, `divider-accent`, `divider-success`, `divider-warning`, `divider-info`, `divider-error` +- direction: `divider-vertical`, `divider-horizontal` +- placement: `divider-start`, `divider-end` + +#### Syntax +```html +<div class="divider {MODIFIER}">{text}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each direction/color/placement class names +- Omit text for a blank divider + + +### dock +Dock (also know as Bottom navigation or Bottom bar) is a UI element that provides navigation options to the user. Dock sticks to the bottom of the screen + +[dock docs](https://daisyui.com/components/dock/) + +#### Class names +- component: `dock` +- part: `dock-label` +- modifier: `dock-active` +- size: `dock-xs`, `dock-sm`, `dock-md`, `dock-lg`, `dock-xl` + +#### Syntax +```html +<div class="dock {MODIFIER}">{CONTENT}</div> +``` +where content is a list of buttons: +```html +<button> + <svg>{icon}</svg> + <span class="dock-label">Text</span> +</button> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the size class names +- To make a button active, add `dock-active` class to the button +- add `<meta name="viewport" content="viewport-fit=cover">` is required for responsivness of the dock in iOS + + +### drawer +Drawer is a grid layout that can show/hide a sidebar on the left or right side of the page + +[drawer docs](https://daisyui.com/components/drawer/) + +#### Class names +- component: `drawer` +- part: `drawer-toggle`, `drawer-content`, `drawer-side`, `drawer-overlay` +- placement: `drawer-end` +- modifier: `drawer-open` +- variant: `is-drawer-open:`, `is-drawer-close:` + +#### Syntax +```html +<div class="drawer {MODIFIER}"> + <input id="my-drawer" type="checkbox" class="drawer-toggle" /> + <div class="drawer-content">{CONTENT}</div> + <div class="drawer-side">{SIDEBAR}</div> +</div> +``` +where {CONTENT} can be navbar, site content, footer, etc +and {SIDEBAR} can be a menu like: +```html +<ul class="menu p-4 w-80 min-h-full bg-base-100 text-base-content"> + <li><a>Item 1</a></li> + <li><a>Item 2</a></li> +</ul> +``` +To open/close the drawer, use a label that points to the `drawer-toggle` input: +```html +<label for="my-drawer" class="btn drawer-button">Open/close drawer</label> +``` +Example: This sidebar is always visible on large screen, can be toggled on small screen: +```html +<div class="drawer lg:drawer-open"> + <input id="my-drawer-3" type="checkbox" class="drawer-toggle" /> + <div class="drawer-content flex flex-col items-center justify-center"> + <!-- Page content here --> + <label for="my-drawer-3" class="btn drawer-button lg:hidden"> + Open drawer + </label> + </div> + <div class="drawer-side"> + <label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label> + <ul class="menu bg-base-200 min-h-full w-80 p-4"> + <!-- Sidebar content here --> + <li><button>Sidebar Item 1</button></li> + <li><button>Sidebar Item 2</button></li> + </ul> + </div> +</div> +``` + +Example: This sidebar is always visible. When it's close we only see iocns, when it's open we see icons and text +```html +<div class="drawer lg:drawer-open"> + <input id="my-drawer-4" type="checkbox" class="drawer-toggle" /> + <div class="drawer-content"> + <!-- Page content here --> + </div> + <div class="drawer-side is-drawer-close:overflow-visible"> + <label for="my-drawer-4" aria-label="close sidebar" class="drawer-overlay"></label> + <div class="is-drawer-close:w-14 is-drawer-open:w-64 bg-base-200 flex flex-col items-start min-h-full"> + <!-- Sidebar content here --> + <ul class="menu w-full grow"> + <!-- list item --> + <li> + <button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Homepage"> + π + <span class="is-drawer-close:hidden">Homepage</span> + </button> + </li> + <!-- list item --> + <li> + <button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings"> + π§ + <span class="is-drawer-close:hidden">Settings</span> + </button> + </li> + </ul> + <!-- button to open/close drawer --> + <div class="m-2 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open"> + <label for="my-drawer-4" class="btn btn-ghost btn-circle drawer-button is-drawer-open:rotate-y-180"> + π + </label> + </div> + </div> + </div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/placement class names +- `id` is required for the `drawer-toggle` input. change `my-drawer` to a unique id according to your needs +- `lg:drawer-open` can be used to make sidebar visible on larger screens +- `drawer-toggle` is a hidden checkbox. Use label with "for" attribute to toggle state +- if you want to open the drawer when a button is clicked, use `<label for="my-drawer" class="btn drawer-button">Open drawer</label>` where `my-drawer` is the id of the `drawer-toggle` input +- when using drawer, every page content must be inside `drawer-content` element. for example navbar, footer, etc should not be outside of `drawer` + + +### dropdown +Dropdown can open a menu or any other element when the button is clicked + +[dropdown docs](https://daisyui.com/components/dropdown/) + +#### Class names +- component: `dropdown` +- part: `dropdown-content` +- placement: `dropdown-start`, `dropdown-center`, `dropdown-end`, `dropdown-top`, `dropdown-bottom`, `dropdown-left`, `dropdown-right` +- modifier: `dropdown-hover`, `dropdown-open`, `dropdown-close` + +#### Syntax +Using details and summary +```html +<details class="dropdown"> + <summary>Button</summary> + <ul class="dropdown-content">{CONTENT}</ul> +</details> +``` + +Using popover API +```html +<button popovertarget="{id}" style="anchor-name:--{anchor}">{button}</button> +<ul class="dropdown-content" popover id="{id}" style="position-anchor:--{anchor}">{CONTENT}</ul> +``` + +Using CSS focus +```html +<div class="dropdown"> + <div tabindex="0" role="button">Button</div> + <ul tabindex="-1" class="dropdown-content">{CONTENT}</ul> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/placement class names +- replace `{id}` and `{anchor}` with a unique name +- For CSS focus dropdowns, use `tabindex="0"` and `role="button"` on the button +- The content can be any HTML element (not just `<ul>`) + + +### fab +FAB (Floating Action Button) stays in the bottom corner of screen. It includes a focusable and accessible element with button role. Clicking or focusing it shows additional buttons (known as Speed Dial buttons) in a vertical arrangement or a flower shape (quarter circle) + +[fab docs](https://daisyui.com/components/fab/) + +#### Class names +- component: `fab` +- part: `fab-close`, `fab-main-action` +- modifier: `fab-flower` + +#### Syntax +A single FAB in the corder of screen +```html +<div class="fab"> + <button class="btn btn-lg btn-circle">{IconOriginal}</button> +</div> +``` +A FAB that opens a 3 other buttons in the corner of page vertically +```html +<div class="fab"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <button class="btn btn-lg btn-circle">{Icon1}</button> + <button class="btn btn-lg btn-circle">{Icon2}</button> + <button class="btn btn-lg btn-circle">{Icon3}</button> +</div> +``` +A FAB that opens a 3 other buttons in the corner of page vertically and they have label text +```html +<div class="fab"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <div>{Label1}<button class="btn btn-lg btn-circle">{Icon1}</button></div> + <div>{Label2}<button class="btn btn-lg btn-circle">{Icon2}</button></div> + <div>{Label3}<button class="btn btn-lg btn-circle">{Icon3}</button></div> +</div> +``` +FAB with rectangle buttons. These are not circular buttons so they can have more content. +```html +<div class="fab"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <button class="btn btn-lg">{Label1}</button> + <button class="btn btn-lg">{Label2}</button> + <button class="btn btn-lg">{Label3}</button> +</div> +``` +FAB with close button. When FAB is open, the original button is replaced with a close button +```html +<div class="fab"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <div class="fab-close">Close <span class="btn btn-circle btn-lg btn-error">β</span></div> + <div>{Label1}<button class="btn btn-lg btn-circle">{Icon1}</button></div> + <div>{Label2}<button class="btn btn-lg btn-circle">{Icon2}</button></div> + <div>{Label3}<button class="btn btn-lg btn-circle">{Icon3}</button></div> +</div> +``` +FAB with Main Action button. When FAB is open, the original button is replaced with a main action button +```html +<div class="fab"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <div class="fab-main-action"> + {LabelMainAction}<button class="btn btn-circle btn-secondary btn-lg">{IconMainAction}</button> + </div> + <div>{Label1}<button class="btn btn-lg btn-circle">{Icon1}</button></div> + <div>{Label2}<button class="btn btn-lg btn-circle">{Icon2}</button></div> + <div>{Label3}<button class="btn btn-lg btn-circle">{Icon3}</button></div> +</div> +``` +FAB Flower. It opens the buttons in a flower shape (quarter circle) arrangement instead of vertical +```html +<div class="fab fab-flower"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <button class="fab-main-action btn btn-circle btn-lg">{IconMainAction}</button> + <button class="btn btn-lg btn-circle">{Icon1}</button> + <button class="btn btn-lg btn-circle">{Icon2}</button> + <button class="btn btn-lg btn-circle">{Icon3}</button> +</div> +``` +FAB Flower with tooltips. There's no space for a text label in a quarter circle, so tooltips are used to indicate the button's function +```html +<div class="fab fab-flower"> + <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div> + <button class="fab-main-action btn btn-circle btn-lg">{IconMainAction}</button> + <div class="tooltip tooltip-left" data-tip="{Label1}"> + <button class="btn btn-lg btn-circle">{Icon1}</button> + </div> + <div class="tooltip tooltip-left" data-tip="{Label2}"> + <button class="btn btn-lg btn-circle">{Icon2}</button> + </div> + <div class="tooltip tooltip-left" data-tip="{Label3}"> + <button class="btn btn-lg btn-circle">{Icon3}</button> + </div> +</div> +``` +#### Rules +- {Icon*} should be replaced with the appropriate icon for each button. SVG icons are recommended +- {IconOriginal} is the icon that we see before opening the FAB +- {IconMainAction} is the icon we see after opening the FAB +- {Icon1}, {Icon2}, {Icon3} are the icons for the additional buttons +- {Label*} is the label text for each button + + +### fieldset +Fieldset is a container for grouping related form elements. It includes fieldset-legend as a title and label as a description + +[fieldset docs](https://daisyui.com/components/fieldset/) + +#### Class names +- Component: `fieldset`, `label` +- Parts: `fieldset-legend` + +#### Syntax +```html +<fieldset class="fieldset"> + <legend class="fieldset-legend">{title}</legend> + {CONTENT} + <p class="label">{description}</p> +</fieldset> +``` + +#### Rules +- You can use any element as a direct child of fieldset to add form elements + + +### file-input +File Input is a an input field for uploading files + +[file-input docs](https://daisyui.com/components/file-input/) + +#### Class Names: +- Component: `file-input` +- Style: `file-input-ghost` +- Color: `file-input-neutral`, `file-input-primary`, `file-input-secondary`, `file-input-accent`, `file-input-info`, `file-input-success`, `file-input-warning`, `file-input-error` +- Size: `file-input-xs`, `file-input-sm`, `file-input-md`, `file-input-lg`, `file-input-xl` + +#### Syntax +```html +<input type="file" class="file-input {MODIFIER}" /> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names + + +### filter +Filter is a group of radio buttons. Choosing one of the options will hide the others and shows a reset button next to the chosen option + +[filter docs](https://daisyui.com/components/filter/) + +#### Class names +- component: `filter` +- part: `filter-reset` + +#### Syntax +Using HTML form +```html +<form class="filter"> + <input class="btn btn-square" type="reset" value="Γ"/> + <input class="btn" type="radio" name="{NAME}" aria-label="Tab 1 title"/> + <input class="btn" type="radio" name="{NAME}" aria-label="Tab 2 title"/> +</form> +``` +Without HTML form +```html +<div class="filter"> + <input class="btn filter-reset" type="radio" name="{NAME}" aria-label="Γ"/> + <input class="btn" type="radio" name="{NAME}" aria-label="Tab 1 title"/> + <input class="btn" type="radio" name="{NAME}" aria-label="Tab 2 title"/> +</div> +``` + +#### Rules +- replace `{NAME}` with proper value, according to the context of the filter +- Each set of radio inputs must have unique `name` attributes to avoid conflicts +- Use `<form>` tag when possible and only use `<div>` if you can't use a HTML form for some reason +- Use `filter-reset` class for the reset button +- Do not check any of the radio inputs by default + + +### footer +Footer can contain logo, copyright notice, and links to other pages + +[footer docs](https://daisyui.com/components/footer/) + +#### Class names +- component: `footer` +- part: `footer-title` +- placement: `footer-center` +- direction: `footer-horizontal`, `footer-vertical` + +#### Syntax +```html +<footer class="footer {MODIFIER}">{CONTENT}</footer> +``` +where content can contain several `<nav>` tags with `footer-title` and links inside + +#### Rules +- {MODIFIER} is optional and can have one of each placement/direction class names +- try to use `sm:footer-horizontal` to make footer responsive +- suggestion - use `base-200` for background color + + +### hero +Hero is a component for displaying a large box or image with a title and description + +[hero docs](https://daisyui.com/components/hero/) + +#### Class names +- component: `hero` +- part: `hero-content`, `hero-overlay` + +#### Syntax +```html +<div class="hero {MODIFIER}">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional +- Use `hero-content` for the text content +- Use `hero-overlay` inside the hero to overlay the background image with a color +- Content can contain a figure + + +### hover-3d +Hover 3D is a wrapper component that adds a 3D hover effect to its content. When we hover over the component, it tilts and rotates based on the mouse position, creating an interactive 3D effect. + +`hover-3d` works by placing 8 hover zones on top of the content. Each zone detects mouse movement and applies a slight rotation to the content based on the mouse position within that zone. The combined effect of all 8 zones creates a smooth and responsive 3D tilt effect as the user moves their mouse over the component. + +Only use non-interactive content inside the `hover-3d` wrapper. If you want to make the entire card clickable, use a link for the whole `hover-3d` component instead of putting interactive elements like buttons or links inside it. + +[hover-3d docs](https://daisyui.com/components/hover-3d/) + +#### Class names +- component: `hover-3d` + +#### Syntax +```html +<div class="hover-3d my-12 mx-2"> + <figure class="max-w-100 rounded-2xl"> + <img src="https://img.daisyui.com/images/stock/creditcard.webp" alt="Tailwind CSS 3D card" /> + </figure> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> +</div> +``` + +#### Rules +- hover-3d can be a `<div>` or a `<a>` +- hover-3d must have exactly 9 direct children where the first child is the main content and the other 8 children are empty `<div>`s for hover zones +- content inside hover-3d should be non-interactive (no buttons, links, inputs, etc) + + +### hover-gallery +Hover Gallery is container of images. The first image is visible be default and when we hover it horizontally, other images show up. Hover Gallery is useful for product cards in ecommerce sites, portfoilios or in image galleries. Hover Gallery can include up to 10 images. + +[hover-gallery docs](https://daisyui.com/components/hover-gallery/) + +#### Class names +- component: `hover-gallery` + +#### Syntax +```html +<figure class="hover-gallery max-w-60"> + <img src="https://img.daisyui.com/images/stock/daisyui-hat-1.webp" /> + <img src="https://img.daisyui.com/images/stock/daisyui-hat-2.webp" /> + <img src="https://img.daisyui.com/images/stock/daisyui-hat-3.webp" /> + <img src="https://img.daisyui.com/images/stock/daisyui-hat-4.webp" /> +</figure> +``` + +#### Rules +- hover-gallery can be a `<div>` or a `<figure>` +- hover-gallery can include up to 10 images +- hover-gallery needs a max width otherwise if fills the container width +- images must be same dimensions for a proper alignment + + +### indicator +Indicators are used to place an element on the corner of another element + +[indicator docs](https://daisyui.com/components/indicator/) + +#### Class names +- component: `indicator` +- part: `indicator-item` +- placement: `indicator-start`, `indicator-center`, `indicator-end`, `indicator-top`, `indicator-middle`, `indicator-bottom` + +#### Syntax +```html +<div class="indicator"> + <span class="indicator-item">{indicator content}</span> + <div>{main content}</div> +</div> +``` + +#### Rules +- Add all indicator elements (with `indicator-item` class) before the main content +- {placement} is optional and can have one of each horizontal/vertical class names. default is `indicator-end indicator-top` + + +### input +Text Input is a simple input field + +[input docs](https://daisyui.com/components/input/) + +#### Class names +- component: `input` +- style: `input-ghost` +- color: `input-neutral`, `input-primary`, `input-secondary`, `input-accent`, `input-info`, `input-success`, `input-warning`, `input-error` +- size: `input-xs`, `input-sm`, `input-md`, `input-lg`, `input-xl` + +#### Syntax +```html +<input type="{type}" placeholder="Type here" class="input {MODIFIER}" /> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names +- Can be used with any input field type (text, password, email, etc.) +- Use `input` class for the parent when you have more than one element inside input + + +### join +Join is a container for grouping multiple items, it can be used to group buttons, inputs, etc. Join applies border radius to the first and last item. Join can be used to create a horizontal or vertical list of items + +[join docs](https://daisyui.com/components/join/) + +#### Class names +- component: `join`, `join-item` +- direction: `join-vertical`, `join-horizontal` + +#### Syntax +```html +<div class="join {MODIFIER}">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the direction class names +- Any direct child of the join element will get joined together +- Any element with `join-item` will be affected +- Use `lg:join-horizontal` for responsive layouts + + +### kbd +Kbd is used to display keyboard shortcuts + +[kbd docs](https://daisyui.com/components/kbd/) + +#### Class names +- component: `kbd` +- size: `kbd-xs`, `kbd-sm`, `kbd-md`, `kbd-lg`, `kbd-xl` + +#### Syntax +```html +<kbd class="kbd {MODIFIER}">K</kbd> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the size class names + + +### label +Label is used to provide a name or title for an input field. Label can be placed before or after the field + +[label docs](https://daisyui.com/components/label/) + +#### Class names +- component: `label`, `floating-label` + +#### Syntax +For regular label: +```html +<label class="input"> + <span class="label">{label text}</span> + <input type="text" placeholder="Type here" /> +</label> +``` +For floating label: +```html +<label class="floating-label"> + <input type="text" placeholder="Type here" class="input" /> + <span>{label text}</span> +</label> +``` + +#### Rules +- The `input` class is for styling the parent element which contains the input field and label, so the label does not have the 'input' class +- Use `floating-label` for the parent of an input field and a span that floats above the input field when the field is focused + + +### link +Link adds the missing underline style to links + +[link docs](https://daisyui.com/components/link/) + +#### Class names +- component: `link` +- style: `link-hover` +- color: `link-neutral`, `link-primary`, `link-secondary`, `link-accent`, `link-success`, `link-info`, `link-warning`, `link-error` + +#### Syntax +```html +<a class="link {MODIFIER}">Click me</a> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names + + +### list +List is a vertical layout to display information in rows + +[list docs](https://daisyui.com/components/list/) + +#### Class Names: +- Component: `list`, `list-row` +- Modifier: `list-col-wrap`, `list-col-grow` + +#### Syntax +```html +<ul class="list"> + <li class="list-row">{CONTENT}</li> +</ul> +``` + +#### Rules +- Use `list-row` for each item inside the list +- By default, the second child of the `list-row` will fill the remaining space. You can use `list-col-grow` on another child to make it fill the remaining space instead +- Use `list-col-wrap` to force an item to wrap to the next line + + +### loading +Loading shows an animation to indicate that something is loading + +[loading docs](https://daisyui.com/components/loading/) + +#### Class names +- component: `loading` +- style: `loading-spinner`, `loading-dots`, `loading-ring`, `loading-ball`, `loading-bars`, `loading-infinity` +- size: `loading-xs`, `loading-sm`, `loading-md`, `loading-lg`, `loading-xl` + +#### Syntax +```html +<span class="loading {MODIFIER}"></span> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the style/size class names + + +### mask +Mask crops the content of the element to common shapes + +[mask docs](https://daisyui.com/components/mask/) + +#### Class names +- component: `mask` +- style: `mask-squircle`, `mask-heart`, `mask-hexagon`, `mask-hexagon-2`, `mask-decagon`, `mask-pentagon`, `mask-diamond`, `mask-square`, `mask-circle`, `mask-star`, `mask-star-2`, `mask-triangle`, `mask-triangle-2`, `mask-triangle-3`, `mask-triangle-4` +- modifier: `mask-half-1`, `mask-half-2` + +#### Syntax +```html +<img class="mask {MODIFIER}" src="{image-url}" /> +``` + +#### Rules +- {MODIFIER} is required and can have one of the style/modifier class names +- You can change the shape of any element using `mask` class names +- You can set custom sizes using `w-*` and `h-*` + + +### menu +Menu is used to display a list of links vertically or horizontally + +[menu docs](https://daisyui.com/components/menu/) + +#### Class names +- component: `menu` +- part: `menu-title`, `menu-dropdown`, `menu-dropdown-toggle` +- modifier: `menu-disabled`, `menu-active`, `menu-focus`, `menu-dropdown-show` +- size: `menu-xs`, `menu-sm`, `menu-md`, `menu-lg`, `menu-xl` +- direction: `menu-vertical`, `menu-horizontal` + +#### Syntax +Vertical menu: +```html +<ul class="menu"> + <li><button>Item</button></li> +</ul> +``` +Horizontal menu: +```html +<ul class="menu menu-horizontal"> + <li><button>Item</button></li> +</ul> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/size/direction class names +- Use `lg:menu-horizontal` for responsive layouts +- Use `menu-title` for list item title +- Use `<details>` tag to make submenus collapsible +- Use `menu-dropdown` and `menu-dropdown-toggle` to toggle the dropdown using JS + + +### mockup-browser +Browser mockup shows a box that looks like a browser window + +[mockup-browser docs](https://daisyui.com/components/mockup-browser/) + +#### Class names +- component: `mockup-browser` +- part: `mockup-browser-toolbar` + +#### Syntax +```html +<div class="mockup-browser"> + <div class="mockup-browser-toolbar"> + {toolbar content} + </div> + <div>{CONTENT}</div> +</div> +``` + +#### Rules +- For a default mockup, use just `mockup-browser` class name +- To set a URL in toolbar, add a div with `input` class + + +### mockup-code +Code mockup is used to show a block of code in a box that looks like a code editor + +[mockup-code docs](https://daisyui.com/components/mockup-code/) + +#### Class names +- component: `mockup-code` + +#### Syntax +```html +<div class="mockup-code"> + <pre data-prefix="$"><code>npm i daisyui</code></pre> +</div> +``` + +#### Rules +- Use `<pre data-prefix="{prefix}">` to show a prefix before each line +- Use `<code>` tag to add code syntax highlighting (requires additional library) +- To highlight a line, add background/text color + + +### mockup-phone +Phone mockup shows a mockup of an iPhone + +[mockup-phone docs](https://daisyui.com/components/mockup-phone/) + +#### Class names +- component: `mockup-phone` +- part: `mockup-phone-camera`, `mockup-phone-display` + +#### Syntax +```html +<div class="mockup-phone"> + <div class="mockup-phone-camera"></div> + <div class="mockup-phone-display">{CONTENT}</div> +</div> +``` + +#### Rules +- Inside `mockup-phone-display` you can add anything + + +### mockup-window +Window mockup shows a box that looks like an operating system window + +[mockup-window docs](https://daisyui.com/components/mockup-window/) + +#### Class names +- component: `mockup-window` + +#### Syntax +```html +<div class="mockup-window"> + <div>{CONTENT}</div> +</div> +``` + + +### modal +Modal is used to show a dialog or a box when you click a button + +[modal docs](https://daisyui.com/components/modal/) + +#### Class names +- component: `modal` +- part: `modal-box`, `modal-action`, `modal-backdrop`, `modal-toggle` +- modifier: `modal-open` +- placement: `modal-top`, `modal-middle`, `modal-bottom`, `modal-start`, `modal-end` + +#### Syntax +Using HTML dialog element +```html +<button onclick="my_modal.showModal()">Open modal</button> +<dialog id="my_modal" class="modal"> + <div class="modal-box">{CONTENT}</div> + <form method="dialog" class="modal-backdrop"><button>close</button></form> +</dialog> +``` + +Using checkbox (legacy) +```html +<label for="my-modal" class="btn">Open modal</label> +<input type="checkbox" id="my-modal" class="modal-toggle" /> +<div class="modal"> + <div class="modal-box">{CONTENT}</div> + <label class="modal-backdrop" for="my-modal">Close</label> +</div> +``` + +Using anchor links (legacy) +```html +<a href="#my-modal" class="btn">Open modal</a> +<div class="modal" id="my-modal"> + <div class="modal-box">{CONTENT}</div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/placement class names +- Add `tabindex="0"` to make modal focusable +- Use unique IDs for each modal +- For HTML dialog element modals, add `<form method="dialog">` for closing the modal with submit + + +### navbar +Navbar is used to show a navigation bar on the top of the page + +[navbar docs](https://daisyui.com/components/navbar/) + +#### Class names +- component: `navbar` +- part: `navbar-start`, `navbar-center`, `navbar-end` + +#### Syntax +```html +<div class="navbar">{CONTENT}</div> +``` + +#### Rules +- use `navbar-start`, `navbar-center`, `navbar-end` to position content horizontally +- put anything inside each section +- suggestion - use `base-200` for background color + + +### pagination +Pagination is a group of buttons + +[pagination docs](https://daisyui.com/components/pagination/) + +#### Class names +- component: `join` +- part: `join-item` +- direction: `join-vertical`, `join-horizontal` + +#### Syntax +```html +<div class="join">{CONTENT}</div> +``` + +#### Rules +- Use `join-item` for each button or link inside the pagination +- Use `btn` class for styling pagination items + + +### progress +Progress bar can be used to show the progress of a task or to show the passing of time + +[progress docs](https://daisyui.com/components/progress/) + +#### Class names +- component: `progress` +- color: `progress-neutral`, `progress-primary`, `progress-secondary`, `progress-accent`, `progress-info`, `progress-success`, `progress-warning`, `progress-error` + +#### Syntax +```html +<progress class="progress {MODIFIER}" value="50" max="100"></progress> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the color class names +- You must specify value and max attributes + + +### radial-progress +Radial progress can be used to show the progress of a task or to show the passing of time + +[radial-progress docs](https://daisyui.com/components/radial-progress/) + +#### Class names +- component: `radial-progress` + +#### Syntax +```html +<div class="radial-progress" style="--value:70;" aria-valuenow="70" role="progressbar">70%</div> +``` + +#### Rules +- The `--value` CSS variable and text must be a number between 0 and 100 +- you need to add `aria-valuenow="{value}"`, `aria-valuenow={value}` so screen readers can properly read value and also show that its a progress element to them +- Use `div` instead of progress because browsers can't show text inside progress tag +- Use `--size` for setting size (default 5rem) and `--thickness` to set how thick the indicator is + + +### radio +Radio buttons allow the user to select one option + +[radio docs](https://daisyui.com/components/radio/) + +#### Class names +- component: `radio` +- color: `radio-neutral`, `radio-primary`, `radio-secondary`, `radio-accent`, `radio-success`, `radio-warning`, `radio-info`, `radio-error` +- size: `radio-xs`, `radio-sm`, `radio-md`, `radio-lg`, `radio-xl` + +#### Syntax +```html +<input type="radio" name="{name}" class="radio {MODIFIER}" /> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the size/color class names +- Replace {name} with a unique name for the radio group +- Each set of radio inputs should have unique `name` attributes to avoid conflicts with other sets of radio inputs on the same page + + +### range +Range slider is used to select a value by sliding a handle + +[range docs](https://daisyui.com/components/range/) + +#### Class names +- component: `range` +- color: `range-neutral`, `range-primary`, `range-secondary`, `range-accent`, `range-success`, `range-warning`, `range-info`, `range-error` +- size: `range-xs`, `range-sm`, `range-md`, `range-lg`, `range-xl` + +#### Syntax +```html +<input type="range" min="0" max="100" value="40" class="range {MODIFIER}" /> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each color/size class names +- You must specify `min` and `max` attributes + + +### rating +Rating is a set of radio buttons that allow the user to rate something + +[rating docs](https://daisyui.com/components/rating/) + +#### Class names +- component: `rating` +- modifier: `rating-half`, `rating-hidden` +- size: `rating-xs`, `rating-sm`, `rating-md`, `rating-lg`, `rating-xl` + +#### Syntax +```html +<div class="rating {MODIFIER}"> + <input type="radio" name="rating-1" class="mask mask-star" /> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/size class names +- Each set of rating inputs should have unique `name` attributes to avoid conflicts with other ratings on the same page +- Add `rating-hidden` for the first radio to make it hidden so user can clear the rating + + +### select +Select is used to pick a value from a list of options + +[select docs](https://daisyui.com/components/select/) + +#### Class names +- component: `select` +- style: `select-ghost` +- color: `select-neutral`, `select-primary`, `select-secondary`, `select-accent`, `select-info`, `select-success`, `select-warning`, `select-error` +- size: `select-xs`, `select-sm`, `select-md`, `select-lg`, `select-xl` + +#### Syntax +```html +<select class="select {MODIFIER}"> + <option>Option</option> +</select> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names + + +### skeleton +Skeleton is a component that can be used to show a loading state + +[skeleton docs](https://daisyui.com/components/skeleton/) + +#### Class names +- component: `skeleton` +- modifier: `skeleton-text` + +#### Syntax +```html +<div class="skeleton"></div> +``` +Example with text skeleton: +```html +<div class="skeleton skeleton-text">Loading data...</div> +``` + +#### Rules +- Add `h-*` and `w-*` utility classes to set height and width + + +### stack +Stack visually puts elements on top of each other + +[stack docs](https://daisyui.com/components/stack/) + +#### Class Names: +- Component: `stack` +- Modifier: `stack-top`, `stack-bottom`, `stack-start`, `stack-end` + +#### Syntax +```html +<div class="stack {MODIFIER}">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier class names +- You can use `w-*` and `h-*` classes to set the width and height of the stack, making all items the same size + + +### stat +Stat is used to show numbers and data in a block + +[stat docs](https://daisyui.com/components/stat/) + +#### Class names +- Component: `stats` +- Part: `stat`, `stat-title`, `stat-value`, `stat-desc`, `stat-figure`, `stat-actions` +- Direction: `stats-horizontal`, `stats-vertical` + +#### Syntax +```html +<div class="stats {MODIFIER}"> + <div class="stat">{CONTENT}</div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the direction class names +- It's horizontal by default but you can make it vertical with the `stats-vertical` class +- Content includes `stat-title`, `stat-value`, `stat-desc` inside a `stat` + + +### status +Status is a really small icon to visually show the current status of an element, like online, offline, error, etc + +[status docs](https://daisyui.com/components/status/) + +#### Class Names: +- Component: `status` +- Color: `status-neutral`, `status-primary`, `status-secondary`, `status-accent`, `status-info`, `status-success`, `status-warning`, `status-error` +- Size: `status-xs`, `status-sm`, `status-md`, `status-lg`, `status-xl` + +#### Syntax +```html +<span class="status {MODIFIER}"></span> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the color/size class names +- This component does not render anything visible + + +### steps +Steps can be used to show a list of steps in a process + +[steps docs](https://daisyui.com/components/steps/) + +#### Class Names: +- Component: `steps` +- Part: `step`, `step-icon` +- Color: `step-neutral`, `step-primary`, `step-secondary`, `step-accent`, `step-info`, `step-success`, `step-warning`, `step-error` +- Direction: `steps-vertical`, `steps-horizontal` + +#### Syntax +```html +<ul class="steps {MODIFIER}"> + <li class="step">{step content}</li> +</ul> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each direction/color class names +- To make a step active, add the `step-primary` class +- You can add an icon in each step using `step-icon` class +- To display data in `data-content` ,use `data-content="{value}"` at the `<li>` + + +### swap +Swap allows you to toggle the visibility of two elements using a checkbox or a class name + +[swap docs](https://daisyui.com/components/swap/) + +#### Class Names: +- Component: `swap` +- Part: `swap-on`, `swap-off`, `swap-indeterminate` +- Modifier: `swap-active` +- Style: `swap-rotate`, `swap-flip` + +#### Syntax +Using checkbox +```html +<label class="swap {MODIFIER}"> + <input type="checkbox" /> + <div class="swap-on">{content when active}</div> + <div class="swap-off">{content when inactive}</div> +</label> +``` + +Using class name +```html +<div class="swap {MODIFIER}"> + <div class="swap-on">{content when active}</div> + <div class="swap-off">{content when inactive}</div> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/style class names +- Use only a hidden checkbox to control swap state or add/remove the `swap-active` class using JS to control state +- To show something when the checkbox is indeterminate, use `swap-indeterminate` class + + +### tab +Tabs can be used to show a list of links in a tabbed format + +[tab docs](https://daisyui.com/components/tab/) + +#### Class Names: +- Component: `tabs` +- Part: `tab`, `tab-content` +- Style: `tabs-box`, `tabs-border`, `tabs-lift` +- Modifier: `tab-active`, `tab-disabled` +- Placement: `tabs-top`, `tabs-bottom` + +#### Syntax +Using buttons: +```html +<div role="tablist" class="tabs {MODIFIER}"> + <button role="tab" class="tab">Tab</button> +</div> +``` + +Using radio inputs: +```html +<div role="tablist" class="tabs tabs-box"> + <input type="radio" name="my_tabs" class="tab" aria-label="Tab" /> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the style/size class names +- Radio inputs are needed for tab content to work with tab click +- If tabs gets a background then every tab inside it becomes rounded from both top corners + + +### table +Table can be used to show a list of data in a table format + +[table docs](https://daisyui.com/components/table/) + +#### Class Names: +- Component: `table` +- Modifier: `table-zebra`, `table-pin-rows`, `table-pin-cols` +- Size: `table-xs`, `table-sm`, `table-md`, `table-lg`, `table-xl` + +#### Syntax +```html +<div class="overflow-x-auto"> + <table class="table {MODIFIER}"> + <thead> + <tr> + <th></th> + </tr> + </thead> + <tbody> + <tr> + <th></th> + </tr> + </tbody> + </table> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each modifier/size class names +- The `overflow-x-auto` class is added to the wrapper div to make the table horizontally scrollable on smaller screens + + +### text-rotate +Text Rotate can show up to 6 lines of text, one at a time, with a an infinite loop animation. Duration is 10 seconds by default. The animation will pause on hover. + +[textarea docs](https://daisyui.com/components/text-rotate/) + +#### Class Names: +- Component: `text-rotate` + +#### Syntax +```html +<span class="text-rotate"> + <span> + <span>Word 1</span> + <span>Word 2</span> + <span>Word 3</span> + <span>Word 4</span> + <span>Word 5</span> + <span>Word 6</span> + </span> +</span> +``` +Example: +Big font size, horizontally centered +```html +<span class="text-rotate max-md:text-3xl text-7xl font-title"> + <span class="justify-items-center"> + <span>DESIGN</span> + <span>DEVELOP</span> + <span>DEPLOY</span> + <span>SCALE</span> + <span>MAINTAIN</span> + <span>REPEAT</span> + </span> +</span> +``` +Rotating words in a sentence, different colors for each word +```html +<span> + Providing AI Agents for + <span class="text-rotate"> + <span> + <span class="bg-teal-400 text-teal-800 px-2">Designers</span> + <span class="bg-red-400 text-red-800 px-2">Developers</span> + <span class="bg-blue-400 text-blue-800 px-2">Managers</span> + </span> + </span> +</span> +``` +Custom line height in case you have a tall font or need more vertical spacing between lines +```html +<span class="text-rotate max-md:text-3xl text-7xl font-title leading-[2]"> + <span class="justify-items-center"> + <span>π DESIGN</span> + <span>π» DEVELOP</span> + <span>π DEPLOY</span> + <span>π± SCALE</span> + <span>π§ MAINTAIN</span> + <span>π REPEAT</span> + </span> +</span> +``` + +#### Rules +- `text-rotate` must have one span or div inside it that contains 2 to 6 spans/divs for each line of text +- Total duration of the loop is 10000 milliseconds by default +- You can set custom duration using `duration-{value}` utility class, where value is in milliseconds (e.g. `duration-12000` for 12 seconds) + + +### textarea +Textarea allows users to enter text in multiple lines + +[textarea docs](https://daisyui.com/components/textarea/) + +#### Class Names: +- Component: `textarea` +- Style: `textarea-ghost` +- Color: `textarea-neutral`, `textarea-primary`, `textarea-secondary`, `textarea-accent`, `textarea-info`, `textarea-success`, `textarea-warning`, `textarea-error` +- Size: `textarea-xs`, `textarea-sm`, `textarea-md`, `textarea-lg`, `textarea-xl` + +#### Syntax +```html +<textarea class="textarea {MODIFIER}" placeholder="Bio"></textarea> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each style/color/size class names + + +### theme-controller +If a checked checkbox input or a checked radio input with theme-controller class exists in the page, The page will have the same theme as that input's value + +[theme-controller docs](https://daisyui.com/components/theme-controller/) + +#### Class names +- component: `theme-controller` + +#### Syntax +```html +<input type="checkbox" value="{theme-name}" class="theme-controller" /> +``` + +#### Rules +- The value attribute of the input element should be a valid daisyUI theme name + + +### timeline +Timeline component shows a list of events in chronological order + +[timeline docs](https://daisyui.com/components/timeline/) + +#### Class Names: +- Component: `timeline` +- Part: `timeline-start`, `timeline-middle`, `timeline-end` +- Modifier: `timeline-snap-icon`, `timeline-box`, `timeline-compact` +- Direction: `timeline-vertical`, `timeline-horizontal` + +#### Syntax +```html +<ul class="timeline {MODIFIER}"> + <li> + <div class="timeline-start">{start}</div> + <div class="timeline-middle">{icon}</div> + <div class="timeline-end">{end}</div> + </li> +</ul> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the modifier/direction class names +- To make a vertical timeline, add the `timeline-vertical` class to the `ul` element or just do nothing (because its the default style.) +- Add `timeline-snap-icon` to snap the icon to the start instead of middle +- Add the `timeline-compact` class to force all items on one side + + +### toast +Toast is a wrapper to stack elements, positioned on the corner of page + +[toast docs](https://daisyui.com/components/toast/) + +#### Class Names: +- Component: `toast` +- Placement: `toast-start`, `toast-center`, `toast-end`, `toast-top`, `toast-middle`, `toast-bottom` + +#### Syntax +```html +<div class="toast {MODIFIER}">{CONTENT}</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of the placement class names + + +### toggle +Toggle is a checkbox that is styled to look like a switch button + +[toggle docs](https://daisyui.com/components/toggle/) + +#### Class Names: +- Component: `toggle` +- Color: `toggle-primary`, `toggle-secondary`, `toggle-accent`, `toggle-neutral`, `toggle-success`, `toggle-warning`, `toggle-info`, `toggle-error` +- Size: `toggle-xs`, `toggle-sm`, `toggle-md`, `toggle-lg`, `toggle-xl` + +#### Syntax +```html +<input type="checkbox" class="toggle {MODIFIER}" /> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each color/size class names + + +### tooltip +Tooltip can be used to show a message when hovering over an element + +[tooltip docs](https://daisyui.com/components/tooltip/) + +#### Class names +- component: `tooltip` +- part: `tooltip-content` +- modifier: `tooltip-open` +- placement: `tooltip-top`, `tooltip-bottom`, `tooltip-left`, `tooltip-right` +- color: `tooltip-primary`, `tooltip-secondary`, `tooltip-accent`, `tooltip-info`, `tooltip-success`, `tooltip-warning`, `tooltip-error` + +#### Syntax +```html +<div class="tooltip {MODIFIER}" data-tip="Tooltip text"> + <button class="btn">Hover me</button> +</div> +``` + +#### Rules +- {MODIFIER} is optional and can have one of each modifier/placement/color class names + + +### validator +Validator class changes the color of form elements to error or success based on input's validation rules + +[validator docs](https://daisyui.com/components/validator/) + +#### Class names +- component: `validator` +- part: `validator-hint` + +#### Syntax +```html +<input type="{type}" class="input validator" required /> +<p class="validator-hint">Error message</p> +``` + +#### Rules +- Use with `input`, `select`, `textarea` diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index d06a7b4..08f07ce 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -9,6 +9,20 @@ > endpoint shipped + version-bumped + LIVE-VERIFIED). Depends on `@dispatch/[email protected]` (see > `wire.reference.md`) + `@dispatch/ui-contract` (see `ui-contract.reference.md`). > +> **2026-06 delta (cache-warming handoff, additive β package still `0.4.0`):** adds +> `POST /chat/warm` (`WarmRequest` β `WarmResponse`) for an on-demand prompt-cache warm, and the +> throughput axis `GET /metrics/throughput` (`ThroughputResponse`/`ThroughputModelStat`/ +> `ThroughputPeriod`). The warm is NEVER persisted/streamed and NEVER folded into a conversation's +> real usage. Pairs with the `cache-warming` conversation-scoped surface + `NumberField` in +> `ui-contract.reference.md`. +> +> **2026-06-11 delta (cache-rate fix handoff, additive β package still `0.4.0`):** `WarmResponse` +> gains `expectedCacheRate` (the warming HEALTH/retention signal, +> `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)`). Consumed FE-side: headlined +> on the "Warm now" result. (No `ui-contract` change β the `cache-warming` surface's new +> `cache-warming-timer` payload + second "cache retention" `stat` ride the EXISTING `custom`/`stat` +> kinds; the FE cache-warming feature parses them.) +> > **0.3.0 change (token + timing metrics):** adds the durable metrics READ endpoint > `GET /conversations/:id/metrics` β `ConversationMetricsResponse` (`{ turns: TurnMetrics[] }`), and > re-exports `StepMetrics` / `TurnMetrics` from `@dispatch/wire`. This is a SEPARATE read axis from @@ -29,6 +43,11 @@ `latestSeq` = last chunk's `seq`, or the requested `sinceSeq` when caught up (empty `chunks`). - `GET /conversations/:id/metrics` β `ConversationMetricsResponse`: every SEALED turn's `TurnMetrics` in turn order (per-turn token + timing; NOT seq-filtered). IMPLEMENTED + LIVE-VERIFIED (probe 17/17). +- `POST /chat/warm` β body `WarmRequest` (JSON) β `200 WarmResponse` (cache-warm usage incl. + `cachePct`); `409 { error }` when the conversation is currently generating; `400 { error }` on a + missing/invalid `conversationId`. The warm is NEVER persisted/streamed/folded into real usage. +- `GET /metrics/throughput?period=day|week|month&date=<...>` β `ThroughputResponse` (token-weighted + tokens/sec per model over the window). Not part of cache-warming; listed for completeness. - WebSocket on :24205 β ONE path-agnostic socket multiplexes surface ops (`@dispatch/ui-contract`) + chat ops (below). Open once, send `WsClientMessage`, receive `WsServerMessage`. Live `AgentEvent` deltas carry `conversationId`+`turnId` but **no `seq`** @@ -113,6 +132,66 @@ export interface ConversationMetricsResponse { readonly turns: readonly TurnMetrics[]; } +/** The aggregation window for `GET /metrics/throughput`. */ +export type ThroughputPeriod = "day" | "week" | "month"; + +/** One model's token-weighted throughput over a period. */ +export interface ThroughputModelStat { + readonly model: string; + readonly tokensPerSecond: number; + readonly totalOutputTokens: number; + readonly totalGenMs: number; + readonly turns: number; +} + +/** Response body for `GET /metrics/throughput?period=...&date=...`. */ +export interface ThroughputResponse { + readonly period: ThroughputPeriod; + readonly date: string; + readonly start: number; // inclusive window start, epoch-ms + readonly end: number; // exclusive window end, epoch-ms + readonly models: readonly ThroughputModelStat[]; +} + +/** + * Request body for `POST /chat/warm` β manually trigger a prompt-cache WARMING + * request for a conversation (e.g. a "warm now" button). The warm replays the + * conversation's existing prefix to refresh the provider cache; it is NEVER + * persisted and NEVER streamed. Pass the SAME `model`/`cwd` the conversation + * chats with so the prefix is byte-identical to a real turn (that's the cache hit). + */ +export interface WarmRequest { + readonly conversationId: string; + readonly model?: string; // `<credentialName>/<model>`; omit = server default + readonly cwd?: string; +} + +/** + * Response body for `POST /chat/warm` (HTTP 200). The warm's usage β never folded + * into the conversation's real usage. A client surfaces `cachePct` as the "last + * warming" cache-hit indicator. A 409 (currently generating) returns `{ error }` instead. + */ +export interface WarmResponse { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** + * **Cache rate** β what fraction of THIS request's prompt was served from cache: + * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). + * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) + */ + readonly cachePct: number; + /** + * **Expected cache (retention)** β of the cacheable prefix this warm touched, how + * much was still warm and read back vs. had to be (re)written: + * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the + * sum is 0). For a healthy warm this is ~**100%**; it drops toward 0 as the cache + * expires/busts. This is the warming HEALTH signal β headline it for "Warm now". + */ + readonly expectedCacheRate: number; +} + // βββ WebSocket chat ops βββββββββββββββββββββββββββββββββββββββββββββββββββββββ // The persistent WS connection multiplexes chat ops (below) with surface ops // (`@dispatch/ui-contract`). Chat `type`s are namespaced (`chat.*`) so they diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md index 3962fc1..00d354f 100644 --- a/.dispatch/ui-contract.reference.md +++ b/.dispatch/ui-contract.reference.md @@ -6,6 +6,13 @@ > file is for READING only. > > **Orchestrator:** this is a SNAPSHOT β regenerate it whenever `ui-contract` changes. +> +> **2026-06 delta (cache-warming handoff):** adds the `NumberField` variant (`kind:"number"`) to +> the `SurfaceField` union, and an OPTIONAL `conversationId?` to `SubscribeMessage` / +> `UnsubscribeMessage` / `InvokeMessage` / `SurfaceMessage` / `SurfaceUpdate` so a surface can be +> CONVERSATION-SCOPED (state differs per conversation, e.g. `cache-warming`) vs GLOBAL (one state for +> all, e.g. `loaded-extensions`). All additive / backward-compatible: a global surface omits +> `conversationId` and behaves exactly as before. (Backend left the package version at `0.1.0`.) ```ts /** @@ -37,6 +44,7 @@ export type SurfaceField = | ProgressField | SelectorField | StatField + | NumberField | ButtonField | CustomField; @@ -71,6 +79,24 @@ export interface StatField { readonly value: string; } +/** + * A settable numeric value plus the action that sets it β the free-value + * counterpart to `selector` (which is a fixed enum). Optional `min`/`max`/`step` + * are SEMANTIC bounds a client may use to validate/step input; `unit` is a + * display hint (e.g. "ms", "s"). The client posts the new number as the action + * payload. Unlike `progress`/`stat` (read-only), this field is interactive. + */ +export interface NumberField { + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; +} + /** A labelled action trigger. */ export interface ButtonField { readonly kind: "button"; @@ -106,10 +132,15 @@ export interface SurfaceCatalogEntry { /** The surface catalog: the list of available surfaces a client can choose to show. */ export type SurfaceCatalog = readonly SurfaceCatalogEntry[]; -/** A live update for a subscribed surface. v1 carries the full new spec. */ +/** + * A live update for a subscribed surface. v1 carries the full new spec. + * `conversationId` is present only for a CONVERSATION-SCOPED surface (tells the + * client which conversation this update is for); a global surface omits it. + */ export interface SurfaceUpdate { readonly surfaceId: string; readonly spec: SurfaceSpec; + readonly conversationId?: string; } // ββ Surface WebSocket protocol (slice 1: surfaces only) ββββββββββββββββββββββ @@ -117,22 +148,34 @@ export interface SurfaceUpdate { /** A client β server message on the surface channel. */ export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage; +/** + * Begin receiving live updates for a surface. For a CONVERSATION-SCOPED surface, + * include the `conversationId` whose state you want; omit it for a global surface. + */ export interface SubscribeMessage { readonly type: "subscribe"; readonly surfaceId: string; + readonly conversationId?: string; } +/** Stop receiving updates for a surface (and the same `conversationId`, if scoped). */ export interface UnsubscribeMessage { readonly type: "unsubscribe"; readonly surfaceId: string; + readonly conversationId?: string; } -/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */ +/** + * Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean, a + * `number` field's new number). For a conversation-scoped surface, include the + * `conversationId` the action targets. + */ export interface InvokeMessage { readonly type: "invoke"; readonly surfaceId: string; readonly actionId: string; readonly payload?: unknown; + readonly conversationId?: string; } /** A server β client message on the surface channel. */ @@ -148,10 +191,15 @@ export interface CatalogMessage { readonly catalog: SurfaceCatalog; } -/** The full current spec for a surface the client just subscribed to. */ +/** + * The full current spec for a surface the client just subscribed to. + * `conversationId` echoes the subscribe's conversation for a conversation-scoped + * surface (so the client routes it), and is absent for a global surface. + */ export interface SurfaceMessage { readonly type: "surface"; readonly spec: SurfaceSpec; + readonly conversationId?: string; } /** A live update for a subscribed surface. */ diff --git a/backend-handoff-cache-warming-timer.md b/backend-handoff-cache-warming-timer.md new file mode 100644 index 0000000..2b1e8c7 --- /dev/null +++ b/backend-handoff-cache-warming-timer.md @@ -0,0 +1,80 @@ +# FE β backend handoff β cache-warming: next-warm timestamp + manual-warm timer reset + +> **Courier doc** (dispatch-web β arch-rewrite, carried by the user). `lsp` does not span the repos. +> **From:** dispatch-web orchestrator Β· **To:** arch-rewrite orchestrator Β· **Courier:** the user. +> Focused ask split out of `backend-handoff.md` CR-3. Two requests, both ADDITIVE / backward-compatible. + +## Why +The FE shipped a Cache Warming view: enabled toggle, minutes+seconds interval, a **live countdown to +the next warm**, a manual **Warm now** button, and a **history** of each warm's hit %. Two of those β +the countdown and a reliably-fresh "last cache %" β can't be done accurately from what the wire +exposes today. The FE currently fakes them best-effort (countdown anchored to the last warm the FE +*observed* + interval; manual warm % taken from the HTTP response because the surface doesn't refresh). +We'd like to make them authoritative. + +## What I found in the backend (so these asks are precise, not guesses) +Read of `packages/cache-warming/src/warmer.ts` + `packages/transport-http/src/app.ts`: + +1. **`POST /chat/warm` bypasses the warmer.** The handler (`transport-http/src/app.ts:240β289`) calls + `warmService.warm(conversationId, β¦)` **directly**. It never goes through `CacheWarmer`, so a manual + warm does NOT: re-arm the automatic timer, update the warmer's `lastPct`, or call `onSurfaceChange()`. + β the cache-warming **surface does not refresh** after a manual warm (no `update` pushed), and the + automatic timer keeps counting from the *previous* warm β a manual warm doesn't reset the cycle. +2. **No next-warm time is tracked.** `armTimer` (`warmer.ts:99`) arms a relative + `timers.setTimer(fn, state.intervalMs)` but stores no absolute fire time, and `ConversationState` + (`warmer.ts:61`) is `{ enabled, intervalMs, active, lastPct, token }`. There is nothing the surface + could carry to tell a client *when* the next warm fires. + +## Ask 1 β serve a machine-readable next-warm (and last-warm) timestamp on the surface +Record the absolute fire time when the timer is armed and expose it (plus the last warm's time) on the +**conversation-scoped `cache-warming` surface**, pushed on every change via the existing +`onSurfaceChange()` (warm completes, toggle, interval change, turn start/settle). + +- `nextWarmAt`: epoch-ms the next automatic warm is scheduled to fire, or `null` when not scheduled + (disabled, or `active` i.e. a turn is generating so the timer is cancelled). +- `lastWarmAt`: epoch-ms of the most recent completed warm, or `null` if none yet. + +**Suggested shape β no `@dispatch/ui-contract` bump needed:** add ONE `custom` field to the spec +(the escape hatch already exists; non-supporting clients gracefully skip it): +```ts +{ + kind: "custom", + rendererId: "cache-warming-timer", + payload: { + nextWarmAt: number | null, // epoch-ms, or null when not scheduled + lastWarmAt: number | null, // epoch-ms, or null when never warmed + }, +} +``` +(If you'd rather not add a field, a `stat` carrying an ISO/epoch string works too, but a machine- +readable `custom` payload is cleanest for the countdown β the FE needs the number, not a display +string.) The FE will read this in its cache-warming feature and render an exact countdown; it already +parses the surface fields itself, so wiring a `cache-warming-timer` renderer is a small FE change. + +## Ask 2 β reset the automatic timer on a manual warm (and refresh the surface) +Confirm or change: a manual `POST /chat/warm` should be treated as "a warm just happened" for that +conversation, i.e. route it through (or notify) the `CacheWarmer` so it: +1. **re-arms the automatic timer** from *now* (the countdown restarts at the full interval), and +2. **updates `lastPct`** from the manual warm's result and **calls `onSurfaceChange()`** so subscribers + get an `update` (this also fixes the surface "last cache %" not refreshing after a manual warm). + +Per the source, none of this happens today (Ask-1 finding #1). The minimal change is a +`CacheWarmer.warmNow(conversationId)` that does what the automatic `fireWarm` already does β warm β +set `lastPct` β `onSurfaceChange()` β `armTimer()` β and have the `/chat/warm` handler call THAT +instead of `warmService.warm` directly. (If you intend manual warms to be a *separate*, non-cycle- +resetting probe, tell us and we'll keep the FE's manual entry purely local β but the user's intent is +that a manual warm resets the cycle.) + +## What the FE does once this lands +- Drop the FE's best-effort countdown anchor and use `nextWarmAt` directly β exact, drift-free + countdown that also reflects generation pauses (null while `active`). +- Render history from the authoritative surface signal (using `lastWarmAt` changes), removing the + FE-side de-dup/identical-pct workaround noted in `reports/cache-warming-feature.md`. + +## References +- Backend: `packages/cache-warming/src/warmer.ts`, `packages/transport-http/src/app.ts:240`, + `packages/cache-warming/src/pure.ts` (surface spec builder), the cache-warming surface + (`id:"cache-warming"`, region `"side"`, conversation-scoped). +- FE: `src/features/cache-warming/` (view-model + view), `backend-handoff.md` CR-3 (superseded by this + doc), the original `frontend-cache-warming-handoff.md`. +- No contract version bump required if Ask 1 uses the `custom` escape hatch; Ask 2 is server-internal. diff --git a/backend-handoff.md b/backend-handoff.md index 2ddebe1..69deebf 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,7 +5,7 @@ > **From:** dispatch-web orchestrator Β· **To:** arch-rewrite orchestrator Β· **Courier:** the user. > `lsp` does NOT span the repos (ORCHESTRATOR Β§5) β every cross-repo ask flows through here. -_Last updated: 2026-06-10 β "Extensions" view shipped FE-side. ONE open ask: CR-1 (Loaded Extensions as a real multi-column table). The surface is already readable today; CR-1 is the enhancement that finishes the user's "nice table" request._ +_Last updated: 2026-06-11 β **Cache-rate fix + retention + CR-3 consumed FE-side** (from `frontend-cache-warming-handoff.md`): (1) per-turn cache rate now reads true on Claude (no FE change); (2) NEW cross-turn **expected cache (retention)** metric in the chat metrics bubble (`computeExpectedCachePct`/`viewExpectedCache`); (3) **CR-3 DONE & consumed** β the countdown is now AUTHORITATIVE off the surface's `cache-warming-timer` `nextWarmAt`/`lastWarmAt` (FE guessing dropped), history keyed off `lastWarmAt`, and `WarmResponse.expectedCacheRate` headlined on "Warm now"; (4) second "cache retention" `stat` parsed. transport mirror regenerated. **Earlier (same day):** `NumberField` + conversation-scoped subscriptions + "Cache Warming" sidebar view. Open asks: CR-1 (Loaded Extensions as a real multi-column table); CR-2 (optional catalog `scope` flag). **CR-3 is RESOLVED** (see Β§2)._ --- @@ -26,6 +26,19 @@ Endpoints in use (HTTP **24203**, WS **24205**, CORS `*`): Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md` (regenerate on any contract bump). +**2026-06-11 re-mirror (cache-warming).** Both `ui-contract` and `transport-contract` were left at their +existing versions by the backend (`[email protected]`, `[email protected]`) but gained ADDITIVE +members; the `file:` deps already resolve them. The FE mirrors were regenerated to match: +- `ui-contract.reference.md`: `NumberField` (`kind:"number"`) + optional `conversationId?` on + `Subscribe`/`Unsubscribe`/`Invoke`/`Surface`/`SurfaceUpdate`. +- `transport-contract.reference.md`: `POST /chat/warm` (`WarmRequest`/`WarmResponse`) + the throughput + axis (`GET /metrics/throughput`, `ThroughputResponse`/`ThroughputModelStat`/`ThroughputPeriod`). +- FE consumed: generic `number` renderer; protocol keyed by `surfaceId` carrying the focused + conversationId with a staleness rule (drop a `surface`/`update` echoing a non-current conversation; + a global no-echo reply is always accepted); store auto-subscribes every catalog surface with the + focused conversationId and re-scopes on conversation switch; `warmNow()` posts `/chat/warm` with the + conversation's current model name. + ## 2. Open asks FOR THE BACKEND ### CR-1 β emit the **Loaded Extensions** surface as a true table @@ -66,6 +79,33 @@ table (e.g. `Name | Version | Trust | Scope`), listing **all** loaded extensions read `0.0.0` (unversioned). If real versions should appear in the table column, bump each extension's manifest `version` β otherwise the column is all `0.0.0`. +### CR-2 (optional, low priority) β a `scope` flag on the surface catalog entry + +The catalog (`SurfaceCatalogEntry`) carries no hint of whether a surface is GLOBAL or +CONVERSATION-SCOPED, so the FE follows the handoff's "always send the focused `conversationId`" +policy. That works (global surfaces ignore it; the FE's routing accepts the no-echo global reply), +but it means the FE **re-subscribes every surface β including global ones like `loaded-extensions` β +on every conversation switch**, which is needless churn (one redundant unsubscribe+subscribe round +trip per global surface per switch; no user-visible bug, the old spec is retained so there's no +flicker). An optional `scope?: "global" | "conversation"` on `SurfaceCatalogEntry` would let the FE +skip re-subscribing globals on switch. **Not blocking** β only raise if cheap. + +### CR-3 β next-warm timestamp + manual-warm timer reset β **RESOLVED β
(backend `bfbad3a`, consumed FE-side)** + +Both asks shipped by the backend (no contract bump β `custom` escape hatch) and are now consumed: +1. **`nextWarmAt` / `lastWarmAt` (epoch-ms)** arrive on the conversation-scoped `cache-warming` surface + as a `custom` field `{ rendererId: "cache-warming-timer", payload: { nextWarmAt, lastWarmAt } }`. + FE: `parseControls` reads them; the countdown is now derived straight from `nextWarmAt` + (`secondsUntilNext(nextWarmAt, now)`) and the history keys off `lastWarmAt` (`observeWarm`). The + old FE best-effort anchor/guess logic was DELETED. +2. **Manual `POST /chat/warm` now re-arms the timer + pushes a surface `update`.** FE: dropped the + workaround of recording history from the HTTP response β history is driven authoritatively by the + surface's `lastWarmAt`; the HTTP `WarmResponse` is still used for the immediate "Warm now" feedback + line (now headlining `expectedCacheRate`). The generic surface-host does NOT render + `cache-warming-timer` (no registered renderer β graceful skip); the cache-warming feature owns it. + +(The standalone courier `backend-handoff-cache-warming-timer.md` is now historical β no open asks.) + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - `GET /conversations` β conversation list / sidebar (history explorer / switcher); could also expose a @@ -8,6 +8,10 @@ "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract", "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "dompurify": "^3.4.5", + "highlight.js": "^11.11.1", + "marked": "^18.0.4", + "marked-highlight": "^2.2.4", }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -320,6 +324,8 @@ "dom-accessibility-api": ["[email protected]", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], @@ -370,6 +376,8 @@ "hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "highlight.js": ["[email protected]", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "html-encoding-sniffer": ["[email protected]", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "http-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -426,6 +434,10 @@ "magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + + "marked-highlight": ["[email protected]", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="], + "math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/package.json b/package.json index 735e278..acef44b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,11 @@ "dependencies": { "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract", "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire" + "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "dompurify": "^3.4.5", + "highlight.js": "^11.11.1", + "marked": "^18.0.4", + "marked-highlight": "^2.2.4" }, "overrides": { "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", diff --git a/src/app.css b/src/app.css index 5db1f25..2c30b5f 100644 --- a/src/app.css +++ b/src/app.css @@ -1,4 +1,6 @@ @import "tailwindcss"; +/* Syntax-highlight theme for fenced code blocks in rendered Markdown. */ +@import "highlight.js/styles/atom-one-dark.min.css"; /* DaisyUI v5 β enable the plugin AND bundle the dracula theme (set as default, applied via <html data-theme="dracula">). Themes not listed here are NOT @@ -7,6 +9,110 @@ themes: dracula --default; } +/* Rendered-Markdown (assistant messages) typography β scoped to .markdown-body + so it never leaks into the rest of the app. */ +.markdown-body { + & p { + margin-block: 0.5em; + &:first-child { + margin-block-start: 0; + } + &:last-child { + margin-block-end: 0; + } + } + & h1, + & h2, + & h3, + & h4, + & h5, + & h6 { + font-weight: 600; + line-height: 1.25; + margin-block: 0.75em 0.25em; + &:first-child { + margin-block-start: 0; + } + } + & h1 { + font-size: 1.4em; + } + & h2 { + font-size: 1.2em; + } + & h3 { + font-size: 1.1em; + } + & ul, + & ol { + padding-inline-start: 1.5em; + margin-block: 0.5em; + } + & ul { + list-style-type: disc; + } + & ol { + list-style-type: decimal; + } + & li { + margin-block: 0.15em; + } + & pre { + overflow-x: auto; + border-radius: var(--radius-box); + margin-block: 0.5em; + } + & pre code { + display: block; + padding: 0.75em 1em; + font-size: 0.8125em; + line-height: 1.5; + } + & :not(pre) > code { + font-size: 0.875em; + padding: 0.15em 0.4em; + border-radius: var(--radius-selector); + background-color: oklch(var(--color-base-content) / 0.1); + } + & blockquote { + border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2); + padding-inline-start: 0.75em; + margin-block: 0.5em; + opacity: 0.8; + } + & a { + color: oklch(var(--color-primary)); + text-decoration: underline; + &:hover { + opacity: 0.8; + } + } + & strong { + font-weight: 600; + } + & table { + width: 100%; + border-collapse: collapse; + margin-block: 0.5em; + font-size: 0.875em; + } + & th, + & td { + border: 1px solid oklch(var(--color-base-content) / 0.15); + padding: 0.4em 0.75em; + text-align: start; + } + & th { + font-weight: 600; + background-color: oklch(var(--color-base-200)); + } + & hr { + border: none; + border-top: 1px solid oklch(var(--color-base-content) / 0.2); + margin-block: 0.75em; + } +} + /* App shell fills the viewport and never scrolls/overflows at the page level β the inner regions (tab strip, chat transcript) own their own scrolling. */ html, diff --git a/src/app/App.svelte b/src/app/App.svelte index f02797e..dae6177 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,8 +1,14 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; import Table from "../components/Table.svelte"; + import { + CacheWarmingView, + manifest as cacheWarmingManifest, + type WarmFeedback, + } from "../features/cache-warming"; import { ChatView, Composer, manifest as chatManifest, ModelSelector } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; + import { manifest as markdownManifest } from "../features/markdown"; import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; import { manifest as tabsManifest, TabBar } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; @@ -10,15 +16,22 @@ let { store }: { store: AppStore } = $props(); + // The backend's conversation-scoped cache-warming surface. Referenced by id at + // the composition root (sanctioned discovery-by-id) to give it a dedicated view + // and keep it out of the generic Extensions surface list β SurfaceView itself + // stays fully generic (it never switches on a surface id). + const CACHE_WARMING_ID = "cache-warming"; + // The view kinds offered in the sidebar's dropdown. Generic data β the // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ { id: "model", label: "Model" }, { id: "extensions", label: "Extensions" }, + { id: "cache-warming", label: "Cache Warming" }, ] as const; - // Default sidebar layout: a Model panel on top, Extensions below. - const initialViews = ["model", "extensions"] as const; + // Default sidebar layout: a Model panel on top, then Extensions, then Cache Warming. + const initialViews = ["model", "extensions", "cache-warming"] as const; // Frontend module list for the "Loaded Modules" view, AGGREGATED from each // feature's public `manifest` export so it can't drift from what's actually @@ -32,6 +45,8 @@ surfaceHostManifest, viewsManifest, conversationCacheManifest, + markdownManifest, + cacheWarmingManifest, ].map((m) => [m.name, m.description] as const); // Right sidebar: open by default on wide screens (pushes the chat aside), @@ -51,6 +66,19 @@ function handleSelectModel(model: string) { store.selectModel(model); } + + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. + async function warmNow(): Promise<WarmFeedback | null> { + const result = await store.warmNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + cachePct: result.response.cachePct, + expectedCacheRate: result.response.expectedCacheRate, + } + : { ok: false, error: result.error }; + } </script> <main class="relative flex h-screen overflow-hidden"> @@ -165,9 +193,20 @@ </section> <section class="mt-4 flex flex-col gap-3"> <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> - {#each store.surfaces as spec (spec.id)} + {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID) as spec (spec.id)} <SurfaceView {spec} onInvoke={handleInvoke} /> {/each} </section> + {:else if kind === "cache-warming"} + <!-- Re-mount per conversation (like ChatView) so the view's local warming + history / manual-warm feedback can't bleed across tabs. --> + {#key store.activeConversationId} + <CacheWarmingView + spec={store.surface(CACHE_WARMING_ID)} + canWarm={store.activeConversationId !== null} + onInvoke={handleInvoke} + {warmNow} + /> + {/key} {/if} {/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 121bd20..1534d1c 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -388,7 +388,14 @@ describe("App component interaction tests", () => { // Extensions is the default view, so the modules table renders immediately. expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); - for (const name of ["chat", "tabs", "surface-host", "views", "conversation-cache"]) { + for (const name of [ + "chat", + "tabs", + "surface-host", + "views", + "conversation-cache", + "markdown", + ]) { expect(screen.getByRole("cell", { name })).toBeInTheDocument(); } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index efbe065..c242d77 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -4,6 +4,8 @@ import type { ConversationHistoryResponse, ConversationMetricsResponse, ModelsResponse, + WarmRequest, + WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { createIdbChunkStore } from "../adapters/idb"; @@ -12,6 +14,7 @@ import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { applyServerMessage, + getSurfaceSpec, type ProtocolState, initialState as protocolInitialState, invoke as protocolInvoke, @@ -30,6 +33,11 @@ import { randomId } from "./uuid"; const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; +/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ +export type WarmResult = + | { readonly ok: true; readonly response: WarmResponse } + | { readonly ok: false; readonly error: string }; + export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; @@ -40,12 +48,19 @@ export interface AppStore { /** Every received surface spec, in catalog order β all auto-subscribed + expanded. */ readonly surfaces: readonly SurfaceSpec[]; readonly lastError: ProtocolState["lastError"]; + /** The current spec for one surface by id (discovery-by-id), or null if absent. */ + surface(surfaceId: string): SurfaceSpec | null; send(text: string): void; selectModel(model: string): void; newDraft(): void; selectTab(conversationId: string): void; closeTab(conversationId: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; + /** + * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). + * Returns null when no conversation is focused (a draft has nothing to warm). + */ + warmNow(): Promise<WarmResult | null>; dispose(): void; } @@ -179,6 +194,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + /** The conversation the surfaces should scope to (undefined for a draft). */ + function focusedConversationId(): string | undefined { + return tabsStore.activeConversationId ?? undefined; + } + function handleServerMessage(msg: SurfaceServerMessage): void { protocol = applyServerMessage(protocol, msg); // Surfaces are auto-expanded: whenever the catalog changes, subscribe to @@ -188,10 +208,16 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } - /** Subscribe to every catalog entry not yet subscribed; unsubscribe stragglers. */ + /** + * Subscribe to every catalog entry, scoped to the focused conversation, and + * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped + * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` + * emits unsubscribe-old + subscribe-new); a global surface ignores the id. + */ function syncSubscriptions(): void { + const cid = focusedConversationId(); for (const entry of protocol.catalog) { - const result = protocolSubscribe(protocol, entry.id); + const result = protocolSubscribe(protocol, entry.id, cid); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); @@ -216,11 +242,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { onMessage: handleServerMessage, onChat: handleChatMessage, onReopen() { - // The server forgot our subscriptions on reconnect; re-send for all - // catalog entries (protocolSubscribe would no-op since they're still in - // our local map, so emit the wire messages directly). - for (const entry of protocol.catalog) { - const msg: SubscribeMessage = { type: "subscribe", surfaceId: entry.id }; + // The server forgot our subscriptions on reconnect; re-send each with the + // conversation it was subscribed under (protocolSubscribe would no-op since + // they're still in our local map, so emit the wire messages directly). + for (const [surfaceId, sub] of protocol.subscriptions) { + const msg: SubscribeMessage = + sub.conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; socket?.send(msg); } }, @@ -292,7 +321,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get surfaces(): readonly SurfaceSpec[] { const out: SurfaceSpec[] = []; for (const entry of protocol.catalog) { - const spec = protocol.subscriptions.get(entry.id); + const spec = getSurfaceSpec(protocol, entry.id); if (spec) out.push(spec); } return out; @@ -301,6 +330,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return protocol.lastError; }, + surface(surfaceId: string): SurfaceSpec | null { + return getSurfaceSpec(protocol, surfaceId); + }, + send(text: string): void { if (tabsStore.activeConversationId === null) { // Draft: promote to tab on first send @@ -320,6 +353,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftConversationId = nextDraftId; refreshActiveChat(); + // The draft became a real conversation: re-scope conversation-scoped + // surfaces (e.g. cache-warming) to its id. + syncSubscriptions(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -344,6 +380,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftStore = createChatFor(nextDraftId, activeModel); draftConversationId = nextDraftId; refreshActiveChat(); + syncSubscriptions(); }, selectTab(conversationId: string): void { @@ -353,6 +390,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { activeModel = tab.model; } refreshActiveChat(); + syncSubscriptions(); }, closeTab(conversationId: string): void { @@ -364,15 +402,42 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } void cache.delete(conversationId); refreshActiveChat(); + syncSubscriptions(); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke(protocol, surfaceId, actionId, payload); + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + focusedConversationId(), + ); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); } }, + + async warmNow(): Promise<WarmResult | null> { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: WarmRequest = { conversationId, model: activeModel }; + try { + const res = await fetchImpl(`${httpBase}/chat/warm`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; + } + return { ok: true, response: (await res.json()) as WarmResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; + } + }, dispose(): void { for (const store of chatStores.values()) { store.dispose(); diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 77c5204..3eec93d 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -2,8 +2,10 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { computeCachePct, + computeExpectedCachePct, computeTps, viewCacheRate, + viewExpectedCache, viewStepMetrics, viewTurnMetrics, } from "./format"; @@ -249,3 +251,60 @@ describe("viewCacheRate", () => { expect(miss.isHit).toBe(false); }); }); + +describe("computeExpectedCachePct", () => { + it("null when there is no prior turn (first turn has no baseline)", () => { + expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("null when the prior turn cached nothing (denominator 0)", () => { + const prev = { inputTokens: 100, outputTokens: 0 }; + const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; + expect(computeExpectedCachePct(current, prev)).toBeNull(); + }); + + it("100% when the whole prior cached prefix was read back (backend worked example)", () => { + // turn 1: cacheRead 0, cacheWrite 5146 β prefix 5146; turn 2 reads 5146 back. + const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; + const current = { + inputTokens: 8462, + outputTokens: 0, + cacheReadTokens: 5146, + cacheWriteTokens: 3313, + }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); + + it("drops below 100% when the cache busted (read < prior prefix)", () => { + const prev = { + inputTokens: 1000, + outputTokens: 0, + cacheReadTokens: 100, + cacheWriteTokens: 900, + }; + const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; + // 500 / (100 + 900) = 50% + expect(computeExpectedCachePct(current, prev)).toBe(50); + }); + + it("clamps to 100 if read somehow exceeds the prior prefix", () => { + const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; + const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); +}); + +describe("viewExpectedCache", () => { + it("null view when it cannot be derived (no prior turn)", () => { + expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("success level + hit flag for full retention", () => { + const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; + const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; + const v = viewExpectedCache(current, prev); + expect(v?.pct).toBe(100); + expect(v?.level).toBe("success"); + expect(v?.isHit).toBe(true); + }); +}); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index cc86976..ee8db60 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -75,6 +75,35 @@ export function viewCacheRate(u: Usage): CacheRateView { return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; } +/** + * Expected cache (retention): of the cache that existed going INTO this turn, how + * much was read back β `clamp01(cacheRead_N / (cacheRead_{N-1} + cacheWrite_{N-1}))`. + * The denominator is the PRIOR turn's cached prefix (what it read + what it wrote). + * Ideally ~100% on every turn after the first; <100% = the cache busted/expired. + * + * Returns `null` when it cannot be derived: no prior turn (`prev === null`) or the + * prior turn cached nothing (denominator <= 0) β distinct from a real 0%. + */ +export function computeExpectedCachePct(current: Usage, prev: Usage | null): number | null { + if (prev === null) return null; + const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); + if (denom <= 0) return null; + const read = current.cacheReadTokens ?? 0; + const rate = read / denom; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); +} + +/** + * Build a view of the cross-turn retention (percentage + colour level + hit flag), + * or `null` when it can't be derived (see `computeExpectedCachePct`). + */ +export function viewExpectedCache(current: Usage, prev: Usage | null): CacheRateView | null { + const pct = computeExpectedCachePct(current, prev); + if (pct === null) return null; + return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; +} + /** Build a formatted view of a turn's aggregate metrics. */ export function viewTurnMetrics(turn: TurnMetrics): TurnMetricsView { const total = totalTokens(turn.usage); diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index 6997ab9..8822159 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,7 +1,9 @@ export { computeCachePct, + computeExpectedCachePct, computeTps, viewCacheRate, + viewExpectedCache, viewStepMetrics, viewTurnMetrics, } from "./format"; diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index d94882d..0b9c0ec 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -526,4 +526,17 @@ describe("interleaveTurnMetrics β cumulative usage (cache total)", () => { expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); }); + + it("carries the prior finalized turn's usage as the retention baseline", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + // first finalized turn has no earlier baseline + expect(tm[0]?.prevTurnUsage).toBeNull(); + // second turn's baseline is the first turn's usage + expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); + expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); + }); }); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index fc30df0..afeb84b 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -79,11 +79,19 @@ export function interleaveTurnMetrics( } // Running cumulative usage across finalized turns (conversation total at each - // entry index), for the per-turn "chat total" cache rate. + // entry index), for the per-turn "chat total" cache rate. Alongside it, the + // previous finalized turn's usage at each index β the baseline for cross-turn + // retention (expected cache). const cumulativeByEntry: Usage[] = []; + const prevUsageByEntry: (Usage | null)[] = []; let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; + let lastFinalizedUsage: Usage | null = null; for (const e of entries) { - if (e.total !== null) runningUsage = addUsage(runningUsage, e.total.usage); + prevUsageByEntry.push(lastFinalizedUsage); + if (e.total !== null) { + runningUsage = addUsage(runningUsage, e.total.usage); + lastFinalizedUsage = e.total.usage; + } cumulativeByEntry.push(runningUsage); } @@ -170,6 +178,7 @@ export function interleaveTurnMetrics( kind: "turn-metrics", turn: entry.total, cumulativeUsage: cumulativeByEntry[seg] ?? entry.total.usage, + prevTurnUsage: prevUsageByEntry[seg] ?? null, }); } } diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index cf2511c..f5557f7 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -52,6 +52,11 @@ export type MetricsRow = readonly turn: TurnMetrics; /** Cumulative usage across all finalized turns up to and including this one. */ readonly cumulativeUsage: Usage; + /** + * Usage of the most recent EARLIER finalized turn, or `null` when this is the + * first finalized turn. The baseline for cross-turn retention (expected cache). + */ + readonly prevTurnUsage: Usage | null; }; /** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts index 25174ea..e7fd161 100644 --- a/src/core/protocol/index.ts +++ b/src/core/protocol/index.ts @@ -1,2 +1,9 @@ -export { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer"; -export type { ProtocolResult, ProtocolState } from "./types"; +export { + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, +} from "./reducer"; +export type { ProtocolResult, ProtocolState, Subscription } from "./types"; diff --git a/src/core/protocol/reducer.test.ts b/src/core/protocol/reducer.test.ts index 57e12f2..c8e517a 100644 --- a/src/core/protocol/reducer.test.ts +++ b/src/core/protocol/reducer.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer"; +import { + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, +} from "./reducer"; const makeSpec = (id: string, title = id) => ({ id, @@ -32,11 +39,10 @@ describe("applyServerMessage β catalog", () => { describe("applyServerMessage β surface", () => { it("sets the spec for a subscribed surface", () => { let s = initialState(); - const result = subscribe(s, "s1"); - s = result.state; + s = subscribe(s, "s1").state; const spec = makeSpec("s1", "Surface 1"); const next = applyServerMessage(s, { type: "surface", spec }); - expect(next.subscriptions.get("s1")).toEqual(spec); + expect(getSurfaceSpec(next, "s1")).toEqual(spec); }); it("ignores a surface message for a non-subscribed surface", () => { @@ -56,7 +62,7 @@ describe("applyServerMessage β update", () => { type: "update", update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, }); - expect(next.subscriptions.get("s1")?.title).toBe("V2"); + expect(getSurfaceSpec(next, "s1")?.title).toBe("V2"); }); it("ignores an update for a non-subscribed surface", () => { @@ -86,7 +92,7 @@ describe("applyServerMessage β error", () => { }); describe("subscribe", () => { - it("emits exactly one subscribe message", () => { + it("emits exactly one subscribe message (global, no conversationId)", () => { const s = initialState(); const result = subscribe(s, "s1"); expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); @@ -96,10 +102,14 @@ describe("subscribe", () => { it("adds the surface to subscriptions with null spec", () => { const s = initialState(); const result = subscribe(s, "s1"); - expect(result.state.subscriptions.get("s1")).toBeNull(); + expect(result.state.subscriptions.get("s1")).toEqual({ + conversationId: undefined, + spec: null, + }); + expect(getSurfaceSpec(result.state, "s1")).toBeNull(); }); - it("is idempotent β second subscribe is a no-op", () => { + it("is idempotent β second subscribe with the same scope is a no-op", () => { let s = initialState(); s = subscribe(s, "s1").state; const result = subscribe(s, "s1"); @@ -108,6 +118,67 @@ describe("subscribe", () => { }); }); +describe("subscribe β conversation-scoped", () => { + it("includes conversationId in the subscribe message", () => { + const s = initialState(); + const result = subscribe(s, "cache-warming", "conv-A"); + expect(result.outgoing).toEqual([ + { type: "subscribe", surfaceId: "cache-warming", conversationId: "conv-A" }, + ]); + expect(result.state.subscriptions.get("cache-warming")?.conversationId).toBe("conv-A"); + }); + + it("re-scopes on conversation switch: unsubscribe old pair then subscribe new", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = applyServerMessage(s, { + type: "surface", + spec: makeSpec("cw", "A-spec"), + conversationId: "conv-A", + }); + const result = subscribe(s, "cw", "conv-B"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + { type: "subscribe", surfaceId: "cw", conversationId: "conv-B" }, + ]); + // previous spec retained until the new one arrives (no flicker) + expect(getSurfaceSpec(result.state, "cw")?.title).toBe("A-spec"); + expect(result.state.subscriptions.get("cw")?.conversationId).toBe("conv-B"); + }); + + it("drops a stale update echoing the previous conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = subscribe(s, "cw", "conv-B").state; // re-scoped to B + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "STALE-A"), conversationId: "conv-A" }, + }); + expect(getSurfaceSpec(next, "cw")).toBeNull(); // stale ignored, no spec yet for B + }); + + it("accepts an update echoing the current conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-B").state; + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "B-spec"), conversationId: "conv-B" }, + }); + expect(getSurfaceSpec(next, "cw")?.title).toBe("B-spec"); + }); + + it("accepts a global (no-echo) surface message even when subscribed with a conversationId", () => { + // loaded-extensions is global: server ignores our conversationId and echoes none. + let s = initialState(); + s = subscribe(s, "loaded-extensions", "conv-A").state; + const next = applyServerMessage(s, { + type: "surface", + spec: makeSpec("loaded-extensions", "Ext"), + }); + expect(getSurfaceSpec(next, "loaded-extensions")?.title).toBe("Ext"); + }); +}); + describe("unsubscribe", () => { it("emits unsubscribe and drops the spec", () => { let s = initialState(); @@ -118,6 +189,15 @@ describe("unsubscribe", () => { expect(result.state.subscriptions.has("s1")).toBe(false); }); + it("includes conversationId for a scoped subscription", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + const result = unsubscribe(s, "cw"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + ]); + }); + it("is a no-op if not subscribed", () => { const s = initialState(); const result = unsubscribe(s, "nope"); @@ -143,6 +223,20 @@ describe("invoke", () => { ]); }); + it("includes conversationId when provided", () => { + const s = initialState(); + const result = invoke(s, "cw", "cache-warming/set-interval", 120, "conv-A"); + expect(result.outgoing).toEqual([ + { + type: "invoke", + surfaceId: "cw", + actionId: "cache-warming/set-interval", + payload: 120, + conversationId: "conv-A", + }, + ]); + }); + it("does not mutate state", () => { const s = initialState(); const result = invoke(s, "s1", "a1"); diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts index 992a918..3d6b1c8 100644 --- a/src/core/protocol/reducer.ts +++ b/src/core/protocol/reducer.ts @@ -2,6 +2,7 @@ import type { InvokeMessage, SubscribeMessage, SurfaceServerMessage, + SurfaceSpec, UnsubscribeMessage, } from "@dispatch/ui-contract"; import type { ProtocolResult, ProtocolState } from "./types"; @@ -15,6 +16,31 @@ export function initialState(): ProtocolState { }; } +// ββ Message builders (respect exactOptionalPropertyTypes: omit `conversationId` +// entirely for a global subscription rather than setting it to `undefined`). ββ + +function subMsg(surfaceId: string, conversationId: string | undefined): SubscribeMessage { + return conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId }; +} + +function unsubMsg(surfaceId: string, conversationId: string | undefined): UnsubscribeMessage { + return conversationId === undefined + ? { type: "unsubscribe", surfaceId } + : { type: "unsubscribe", surfaceId, conversationId }; +} + +/** + * Is an inbound spec/update (which echoes `echoedId`) current for the + * subscription whose desired scope is `desiredId`? A scoped surface echoes its + * conversationId, so it must match the one we last subscribed with; a GLOBAL + * surface echoes nothing (`undefined`) and is always current. + */ +function isCurrent(desiredId: string | undefined, echoedId: string | undefined): boolean { + return echoedId === undefined || echoedId === desiredId; +} + /** Fold an inbound server message into the next protocol state. */ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState { switch (msg.type) { @@ -22,18 +48,21 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa return { ...state, catalog: msg.catalog }; case "surface": { - const surfaceId = msg.spec.id; - if (!state.subscriptions.has(surfaceId)) return state; + const sub = state.subscriptions.get(msg.spec.id); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, msg.conversationId)) return state; const subs = new Map(state.subscriptions); - subs.set(surfaceId, msg.spec); + subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); return { ...state, subscriptions: subs }; } case "update": { - const surfaceId = msg.update.surfaceId; - if (!state.subscriptions.has(surfaceId)) return state; + const { surfaceId, spec, conversationId } = msg.update; + const sub = state.subscriptions.get(surfaceId); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, conversationId)) return state; const subs = new Map(state.subscriptions); - subs.set(surfaceId, msg.update.spec); + subs.set(surfaceId, { conversationId: sub.conversationId, spec }); return { ...state, subscriptions: subs }; } @@ -43,40 +72,72 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa } /** - * Subscribe to a surface. Idempotent: if already subscribed, returns the same - * state with no outgoing message. + * Subscribe to a surface for a given conversation (omit `conversationId` for a + * GLOBAL surface / when no conversation is focused). + * + * - Not yet subscribed β emits one `subscribe`. + * - Already subscribed with the SAME scope β idempotent no-op. + * - Already subscribed with a DIFFERENT conversation (a re-scope on conversation + * switch) β emits `unsubscribe` for the old pair then `subscribe` for the new + * one, retaining the previous spec until the new one arrives (no flicker). */ -export function subscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - if (state.subscriptions.has(surfaceId)) { +export function subscribe( + state: ProtocolState, + surfaceId: string, + conversationId?: string, +): ProtocolResult { + const existing = state.subscriptions.get(surfaceId); + if (existing !== undefined && existing.conversationId === conversationId) { return { state, outgoing: [] }; } const subs = new Map(state.subscriptions); - subs.set(surfaceId, null); - const outgoing: SubscribeMessage = { type: "subscribe", surfaceId }; - return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; + const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; + const priorSpec: SurfaceSpec | null = existing?.spec ?? null; + if (existing !== undefined) { + outgoing.push(unsubMsg(surfaceId, existing.conversationId)); + } + subs.set(surfaceId, { conversationId, spec: priorSpec }); + outgoing.push(subMsg(surfaceId, conversationId)); + return { state: { ...state, subscriptions: subs }, outgoing }; } /** - * Unsubscribe from a surface. Drops the local spec and emits one unsubscribe. - * If not subscribed, returns the same state with no outgoing. + * Unsubscribe from a surface. Drops the local subscription and emits one + * `unsubscribe` (for the conversation pair it was subscribed under). No-op if + * not subscribed. */ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - if (!state.subscriptions.has(surfaceId)) { + const existing = state.subscriptions.get(surfaceId); + if (existing === undefined) { return { state, outgoing: [] }; } const subs = new Map(state.subscriptions); subs.delete(surfaceId); - const outgoing: UnsubscribeMessage = { type: "unsubscribe", surfaceId }; - return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; + return { + state: { ...state, subscriptions: subs }, + outgoing: [unsubMsg(surfaceId, existing.conversationId)], + }; } -/** Invoke a field's action on a surface. Emits an InvokeMessage; no state change. */ +/** + * Invoke a field's action on a surface. Emits an InvokeMessage (carrying + * `conversationId` for a scoped surface); no state change. + */ export function invoke( state: ProtocolState, surfaceId: string, actionId: string, payload?: unknown, + conversationId?: string, ): ProtocolResult { - const outgoing: InvokeMessage = { type: "invoke", surfaceId, actionId, payload }; + const outgoing: InvokeMessage = + conversationId === undefined + ? { type: "invoke", surfaceId, actionId, payload } + : { type: "invoke", surfaceId, actionId, payload, conversationId }; return { state, outgoing: [outgoing] }; } + +/** The current spec for a subscribed surface, or `null` if absent/unsubscribed. */ +export function getSurfaceSpec(state: ProtocolState, surfaceId: string): SurfaceSpec | null { + return state.subscriptions.get(surfaceId)?.spec ?? null; +} diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts index effec0d..db8886a 100644 --- a/src/core/protocol/types.ts +++ b/src/core/protocol/types.ts @@ -5,12 +5,27 @@ import type { SurfaceSpec, } from "@dispatch/ui-contract"; +/** + * One surface subscription's local state. + * + * `conversationId` is the conversation we last subscribed this surface WITH + * (`undefined` = subscribed globally, no conversation in focus). It is the + * "desired" scope: an inbound `surface`/`update` that echoes a DIFFERENT + * conversation is stale (we have since re-scoped) and is dropped. A GLOBAL + * surface ignores the id server-side and echoes none β that (`undefined` echo) + * is always accepted. `spec` is `null` until the first `surface` arrives. + */ +export interface Subscription { + readonly conversationId: string | undefined; + readonly spec: SurfaceSpec | null; +} + /** The client-side view of the surface protocol state. */ export interface ProtocolState { /** The latest catalog received from the server (empty until first CatalogMessage). */ readonly catalog: SurfaceCatalog; - /** Surfaces the client intends to be subscribed to; null = subscribed but no spec yet. */ - readonly subscriptions: ReadonlyMap<string, SurfaceSpec | null>; + /** Surfaces the client intends to be subscribed to, keyed by surfaceId. */ + readonly subscriptions: ReadonlyMap<string, Subscription>; /** The last error received from the server, if any. */ readonly lastError: SurfaceErrorMessage | null; } diff --git a/src/features/cache-warming/index.ts b/src/features/cache-warming/index.ts new file mode 100644 index 0000000..c432de6 --- /dev/null +++ b/src/features/cache-warming/index.ts @@ -0,0 +1,8 @@ +export type { WarmFeedback, WarmNow } from "./logic/view-model"; +export { default as CacheWarmingView } from "./ui/CacheWarmingView.svelte"; + +/** Public module manifest β aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "cache-warming", + description: "Prompt-cache warming controls, history, and countdown", +} as const; diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts new file mode 100644 index 0000000..3d6f6d0 --- /dev/null +++ b/src/features/cache-warming/logic/view-model.test.ts @@ -0,0 +1,220 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import { + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + parsePct, + secondsUntilNext, + statusForPct, + toMinSec, +} from "./view-model"; + +const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({ + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields, +}); + +describe("parsePct", () => { + it("parses a percentage string", () => { + expect(parsePct("100%")).toBe(100); + expect(parsePct("93 %")).toBe(93); + expect(parsePct("0%")).toBe(0); + }); + it("returns null for a dash / non-numeric", () => { + expect(parsePct("β")).toBeNull(); + expect(parsePct("n/a")).toBeNull(); + }); +}); + +describe("parseControls", () => { + it("returns empty defaults for a null spec", () => { + const c = parseControls(null); + expect(c).toEqual({ + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, + }); + }); + + it("extracts toggle / number / both stats / timer by kind", () => { + const c = parseControls( + spec([ + { + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }, + { + kind: "number", + label: "Interval", + value: 240, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + { kind: "stat", label: "Cache retention", value: "100%" }, + { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, + }, + ]), + ); + expect(c).toEqual({ + enabled: true, + toggleActionId: "cache-warming/toggle", + intervalSeconds: 240, + setIntervalActionId: "cache-warming/set-interval", + lastPct: 61, + retentionPct: 100, + nextWarmAt: 1_700_000_240_000, + lastWarmAt: 1_700_000_000_000, + }); + }); + + it("tells the retention stat apart from the rate stat by label", () => { + const c = parseControls( + spec([ + { kind: "stat", label: "Cache retention", value: "100%" }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + ]), + ); + expect(c.retentionPct).toBe(100); + expect(c.lastPct).toBe(61); + }); + + it("treats a 'β' stat as no pct", () => { + const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "β" }])); + expect(c.lastPct).toBeNull(); + }); + + it("ignores an unknown custom renderer and a malformed timer payload", () => { + const c = parseControls( + spec([ + { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, + { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, + ]), + ); + expect(c.nextWarmAt).toBeNull(); + expect(c.lastWarmAt).toBeNull(); + }); +}); + +describe("interval β min/sec", () => { + it("clampSeconds caps at 0..59", () => { + expect(clampSeconds(75)).toBe(59); + expect(clampSeconds(-3)).toBe(0); + expect(clampSeconds(30)).toBe(30); + expect(clampSeconds(Number.NaN)).toBe(0); + }); + it("clampMinutes floors at 0", () => { + expect(clampMinutes(-1)).toBe(0); + expect(clampMinutes(4)).toBe(4); + }); + it("toMinSec splits total seconds", () => { + expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); + expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); + expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); + }); + it("fromMinSec combines (clamping seconds to 59)", () => { + expect(fromMinSec(4, 0)).toBe(240); + expect(fromMinSec(2, 5)).toBe(125); + expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 + }); +}); + +describe("status + formatting", () => { + it("statusForPct buckets high/mid/low", () => { + expect(statusForPct(100)).toBe("success"); + expect(statusForPct(80)).toBe("success"); + expect(statusForPct(60)).toBe("warning"); + expect(statusForPct(40)).toBe("warning"); + expect(statusForPct(10)).toBe("error"); + }); + it("colorClass maps to literal DaisyUI classes", () => { + expect(colorClass("success")).toBe("text-success"); + expect(colorClass("warning")).toBe("text-warning"); + expect(colorClass("error")).toBe("text-error"); + }); + it("formatWarmLabel matches the manual-warm phrasing", () => { + expect(formatWarmLabel(100)).toBe("Warmed β 100% cache hit"); + expect(formatWarmLabel(92.6)).toBe("Warmed β 93% cache hit"); + }); + it("formatCountdown renders s and m:ss", () => { + expect(formatCountdown(9)).toBe("9s"); + expect(formatCountdown(59)).toBe("59s"); + expect(formatCountdown(60)).toBe("1:00"); + expect(formatCountdown(185)).toBe("3:05"); + expect(formatCountdown(-5)).toBe("0s"); + }); +}); + +describe("warming history reducer (observeWarm)", () => { + it("starts empty", () => { + const s = initialWarmingState(); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBeNull(); + }); + + it("records a new entry on each new authoritative lastWarmAt", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 90); + expect(s.history).toEqual([ + { pct: 90, at: 2000 }, + { pct: 100, at: 1000 }, + ]); + expect(s.lastWarmAt).toBe(2000); + }); + + it("de-duplicates on the timestamp, not the pct (a re-pushed surface β no dup)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); // warm + s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt β skip + expect(s.history).toHaveLength(1); + }); + + it("records two warms with the SAME pct (distinct timestamps both count)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 100); + expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); + }); + + it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { + let s = initialWarmingState(); + s = observeWarm(s, null, 100); + expect(s.history).toEqual([]); + s = observeWarm(s, 1000, null); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBe(1000); + }); +}); + +describe("secondsUntilNext (authoritative, from nextWarmAt)", () => { + it("is null when nothing is scheduled (nextWarmAt null)", () => { + expect(secondsUntilNext(null, 5000)).toBeNull(); + }); + + it("counts down to nextWarmAt, floored at 0", () => { + expect(secondsUntilNext(10_000, 10_000)).toBe(0); + expect(secondsUntilNext(250_000, 10_000)).toBe(240); + expect(secondsUntilNext(70_000, 10_000)).toBe(60); + expect(secondsUntilNext(5_000, 999_999)).toBe(0); // already past + }); +}); diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts new file mode 100644 index 0000000..f7740d7 --- /dev/null +++ b/src/features/cache-warming/logic/view-model.ts @@ -0,0 +1,242 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; + +/** + * Pure core for the cache-warming view β zero DOM, zero effects, zero Svelte. + * + * The backend's `cache-warming` surface carries a toggle, a number interval (in + * seconds), two `stat`s ("last cache rate" + "cache retention"), and a `custom` + * `cache-warming-timer` field bearing the AUTHORITATIVE `nextWarmAt`/`lastWarmAt` + * epoch-ms timestamps. This module turns those inputs into the view-model the + * (thin) Svelte component renders: parsed controls, a warming-history reducer + * keyed off the authoritative `lastWarmAt`, an authoritative countdown, and the + * status/format helpers. + */ + +// ββ Manual-warm port (consumer-defines-port; the composition root adapts the +// store's `POST /chat/warm` result to this shape). ββββββββββββββββββββββββββ +export type WarmFeedback = + | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } + | { readonly ok: false; readonly error: string }; + +export type WarmNow = () => Promise<WarmFeedback | null>; + +// ββ Parsed surface controls βββββββββββββββββββββββββββββββββββββββββββββββββββ + +export interface ParsedControls { + readonly enabled: boolean; + readonly toggleActionId: string | null; + readonly intervalSeconds: number; + readonly setIntervalActionId: string | null; + /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "β"/absent). */ + readonly lastPct: number | null; + /** Cross-turn retention %, from the "cache retention" stat (`null` when "β"/absent). */ + readonly retentionPct: number | null; + /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ + readonly nextWarmAt: number | null; + /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ + readonly lastWarmAt: number | null; +} + +const EMPTY_CONTROLS: ParsedControls = { + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, +}; + +/** The `cache-warming-timer` custom field's renderer id (this feature owns it). */ +const TIMER_RENDERER_ID = "cache-warming-timer"; + +/** Parse a stat's display string (e.g. "100%", "93 %", "β") into a number or null. */ +export function parsePct(value: string): number | null { + const match = value.match(/-?\d+(?:\.\d+)?/); + if (match === null) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; +} + +/** A finite number, else null. */ +function numOrNull(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +/** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */ +function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } { + if (typeof payload !== "object" || payload === null) { + return { nextWarmAt: null, lastWarmAt: null }; + } + const p = payload as Record<string, unknown>; + return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; +} + +/** + * Extract the cache-warming controls from the surface spec by FIELD KIND. The + * surface has one toggle, one number, two stats (rate + retention, told apart by + * label), and one `custom` timer field. Returns empty defaults when the spec is + * absent. + */ +export function parseControls(spec: SurfaceSpec | null): ParsedControls { + if (spec === null) return EMPTY_CONTROLS; + let enabled = false; + let toggleActionId: string | null = null; + let intervalSeconds = 0; + let setIntervalActionId: string | null = null; + let lastPct: number | null = null; + let retentionPct: number | null = null; + let nextWarmAt: number | null = null; + let lastWarmAt: number | null = null; + let seenToggle = false; + let seenNumber = false; + let seenRateStat = false; + for (const field of spec.fields) { + if (field.kind === "toggle" && !seenToggle) { + enabled = field.value; + toggleActionId = field.action.actionId; + seenToggle = true; + } else if (field.kind === "number" && !seenNumber) { + intervalSeconds = field.value; + setIntervalActionId = field.action.actionId; + seenNumber = true; + } else if (field.kind === "stat") { + // Retention is told apart by its label; everything else is the cache rate + // (first one wins, so a stray later stat can't clobber it). + if (/retention/i.test(field.label)) { + retentionPct = parsePct(field.value); + } else if (!seenRateStat) { + lastPct = parsePct(field.value); + seenRateStat = true; + } + } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { + const timer = parseTimer(field.payload); + nextWarmAt = timer.nextWarmAt; + lastWarmAt = timer.lastWarmAt; + } + } + return { + enabled, + toggleActionId, + intervalSeconds, + setIntervalActionId, + lastPct, + retentionPct, + nextWarmAt, + lastWarmAt, + }; +} + +// ββ Interval β minutes/seconds (seconds capped at 59) βββββββββββββββββββββββββ + +export interface MinSec { + readonly minutes: number; + readonly seconds: number; +} + +export function clampSeconds(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.min(59, Math.max(0, Math.floor(n))); +} + +export function clampMinutes(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.floor(n)); +} + +export function toMinSec(totalSeconds: number): MinSec { + const total = Math.max(0, Math.floor(totalSeconds)); + return { minutes: Math.floor(total / 60), seconds: total % 60 }; +} + +/** Combine a minutes + seconds pair (each clamped) into total seconds. */ +export function fromMinSec(minutes: number, seconds: number): number { + return clampMinutes(minutes) * 60 + clampSeconds(seconds); +} + +// ββ Status + formatting βββββββββββββββββββββββββββββββββββββββββββββββββββββββ + +export type WarmStatus = "success" | "warning" | "error"; + +/** Cache-hit % β semantic status (green high, yellow mid, red low). */ +export function statusForPct(pct: number): WarmStatus { + if (pct >= 80) return "success"; + if (pct >= 40) return "warning"; + return "error"; +} + +/** A status β its DaisyUI text-colour class (full literal so Tailwind keeps it). */ +export function colorClass(status: WarmStatus): string { + switch (status) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-error"; + } +} + +/** The status line for a warm, matching the manual-warm feedback phrasing. */ +export function formatWarmLabel(pct: number): string { + return `Warmed β ${Math.round(pct)}% cache hit`; +} + +/** Seconds β a short countdown string (e.g. "3:05", "9s"). */ +export function formatCountdown(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, "0")}`; +} + +// ββ Warming history reducer (keyed off the authoritative `lastWarmAt`) βββββββββ + +export interface WarmEntry { + readonly pct: number; + /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ + readonly at: number; +} + +export interface WarmingViewState { + /** Warmings, MOST RECENT FIRST. */ + readonly history: readonly WarmEntry[]; + /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ + readonly lastWarmAt: number | null; +} + +const MAX_HISTORY = 50; + +export function initialWarmingState(): WarmingViewState { + return { history: [], lastWarmAt: null }; +} + +/** + * Fold the surface's authoritative `lastWarmAt` + current "last cache rate" into + * history. Records a new entry only when `lastWarmAt` CHANGED (a toggle/interval + * update re-pushes the same timestamp β no entry), de-duplicated on the timestamp + * (not the pct, so two warms with the same % both count). A null `lastWarmAt` is + * ignored; a null pct advances the de-dup key without adding an entry. + */ +export function observeWarm( + state: WarmingViewState, + lastWarmAt: number | null, + pct: number | null, +): WarmingViewState { + if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; + if (pct === null) return { ...state, lastWarmAt }; + const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); + return { history, lastWarmAt }; +} + +/** + * Seconds until the next automatic warm, AUTHORITATIVE: derived straight from the + * backend's `nextWarmAt` epoch-ms (never FE-anchored/guessed). `null` when nothing + * is scheduled (disabled, or a turn is generating so the timer is cancelled). + */ +export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { + if (nextWarmAt === null) return null; + return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); +} diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte new file mode 100644 index 0000000..ced5e99 --- /dev/null +++ b/src/features/cache-warming/ui/CacheWarmingView.svelte @@ -0,0 +1,234 @@ +<script lang="ts"> + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { onMount, untrack } from "svelte"; + import { + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + secondsUntilNext, + statusForPct, + toMinSec, + type WarmingViewState, + type WarmNow, + } from "../logic/view-model"; + + let { + spec, + canWarm, + onInvoke, + warmNow, + }: { + /** The cache-warming surface spec for the focused conversation, or null. */ + spec: SurfaceSpec | null; + /** Whether a real conversation is focused (a draft has nothing to warm). */ + canWarm: boolean; + onInvoke: (msg: InvokeMessage) => void; + warmNow: WarmNow; + } = $props(); + + const controls = $derived(parseControls(spec)); + + // View-model state (pure reducer) + the injected clock β owned here, not ambient. + let vm = $state<WarmingViewState>(initialWarmingState()); + let now = $state(Date.now()); + let warming = $state(false); + let errorText = $state<string | null>(null); + // Transient result of the most recent manual warm (immediate feedback; history + // itself is driven authoritatively by the surface's `lastWarmAt`). + let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); + + // Local interval inputs, seeded from the surface and re-seeded only when the + // surface's interval differs from what's shown (so a stray update mid-edit + // doesn't clobber typing). + let minutes = $state(0); + let seconds = $state(0); + + onMount(() => { + const id = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(id); + }); + + // Fold each authoritative warm (new `lastWarmAt`) into history. + $effect(() => { + const at = controls.lastWarmAt; + const pct = controls.lastPct; + untrack(() => { + vm = observeWarm(vm, at, pct); + }); + }); + + // Keep the min/sec inputs in sync with the surface's interval. + $effect(() => { + const target = controls.intervalSeconds; + untrack(() => { + if (fromMinSec(minutes, seconds) !== target) { + const ms = toMinSec(target); + minutes = ms.minutes; + seconds = ms.seconds; + } + }); + }); + + const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); + const history = $derived(vm.history); + const latest = $derived(history[0] ?? null); + const earlier = $derived(history.slice(1)); + + function commitInterval() { + const actionId = controls.setIntervalActionId; + if (actionId === null || spec === null) return; + onInvoke({ type: "invoke", surfaceId: spec.id, actionId, payload: fromMinSec(minutes, seconds) }); + } + + function onMinutes(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input β ignore, don't clobber to 0 + minutes = clampMinutes(next); + commitInterval(); + } + + function onSeconds(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input β ignore, don't clobber to 0 + seconds = clampSeconds(next); + commitInterval(); + } + + function onToggle() { + const actionId = controls.toggleActionId; + if (actionId === null || spec === null) return; + // The toggle action FLIPS server-side; no payload. + onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); + } + + async function handleWarm() { + if (warming) return; + warming = true; + errorText = null; + const result = await warmNow(); + warming = false; + if (result === null) return; + if (result.ok) { + // Immediate feedback only β the authoritative surface `update` (new + // `lastWarmAt`) drives the history via `observeWarm`. + manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; + } else { + manualResult = null; + errorText = result.error; + } + } +</script> + +<div class="flex flex-col gap-3"> + <!-- Enabled --> + <label class="flex items-center justify-between gap-2 text-sm"> + <span>Enabled</span> + <input + type="checkbox" + class="toggle toggle-sm toggle-success" + checked={controls.enabled} + disabled={spec === null} + onchange={onToggle} + /> + </label> + + <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> + <div class="flex items-center justify-between gap-2 text-sm"> + <span>Refresh interval</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + value={minutes} + disabled={spec === null} + onchange={onMinutes} + aria-label="Interval minutes" + /> + <span class="opacity-60">m</span> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + max="59" + value={seconds} + disabled={spec === null} + onchange={onSeconds} + aria-label="Interval seconds" + /> + <span class="opacity-60">s</span> + </span> + </div> + + <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> + {#if !controls.enabled} + <p class="text-xs opacity-50">Warming paused.</p> + {:else if remaining !== null} + <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> + {:else} + <p class="text-xs opacity-50">Next warm: waitingβ¦</p> + {/if} + + <!-- Cross-turn retention (the "is warming working?" health signal) --> + {#if controls.retentionPct !== null} + <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> + Cache retention: {controls.retentionPct}% + </p> + {/if} + + <!-- Manual trigger --> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canWarm || warming} + onclick={handleWarm} + > + {#if warming} + <span class="loading loading-spinner loading-xs"></span> + Warmingβ¦ + {:else} + Warm now + {/if} + </button> + + {#if !canWarm} + <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> + {:else if errorText} + <p class="text-xs text-error">{errorText}</p> + {:else if manualResult} + <!-- Headline the retention (cache health) over the raw hit %. --> + <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> + Warmed β {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached) + </p> + {/if} + + <!-- Warming history: collapse whose title is the most recent warm, coloured by + hit %, with the earlier warmings inside. --> + {#if latest} + <div class="collapse collapse-arrow bg-base-200"> + <input type="checkbox" aria-label="Toggle warming history" /> + <div class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(statusForPct(latest.pct))}"> + {formatWarmLabel(latest.pct)} + </div> + <div class="collapse-content flex flex-col gap-1 text-sm"> + {#if earlier.length > 0} + {#each earlier as entry, i (i)} + <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> + {/each} + {:else} + <p class="text-xs opacity-60">No earlier warmings.</p> + {/if} + </div> + </div> + {:else} + <p class="text-xs opacity-60">No warming yet.</p> + {/if} +</div> diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 3d1421d..00691aa 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -3,10 +3,12 @@ import { interleaveTurnMetrics, viewCacheRate, + viewExpectedCache, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; + import { Markdown } from "../../markdown"; const badgeClass = { success: "badge-success", @@ -113,7 +115,7 @@ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> + <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -146,6 +148,7 @@ {@const turnView = viewTurnMetrics(row.turn)} {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} + {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> @@ -163,6 +166,12 @@ <span class="opacity-70">Chat Total:</span> <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> </span> + {#if retention} + <span class="flex items-center gap-1"> + <span class="opacity-70">Retention:</span> + <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> + </span> + {/if} </div> </div> </div> diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts new file mode 100644 index 0000000..f5406b2 --- /dev/null +++ b/src/features/markdown/index.ts @@ -0,0 +1,8 @@ +export { renderMarkdown } from "./logic/markdown"; +export { default as Markdown } from "./ui/Markdown.svelte"; + +/** Public module manifest β aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "markdown", + description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", +} as const; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts new file mode 100644 index 0000000..7dbb878 --- /dev/null +++ b/src/features/markdown/logic/markdown.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { renderMarkdown } from "./markdown"; + +describe("renderMarkdown", () => { + it("renders GFM markdown (headings, emphasis)", () => { + const html = renderMarkdown("# Title\n\nSome **bold** text."); + expect(html).toContain("<h1"); + expect(html).toContain("Title"); + expect(html).toContain("<strong>bold</strong>"); + }); + + it("highlights fenced code for a known language", () => { + const html = renderMarkdown("```javascript\nconst x = 1;\n```"); + expect(html).toContain("language-javascript"); + expect(html).toContain("hljs-keyword"); // `const` got highlighted + }); + + it("resolves language aliases (js -> javascript)", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("hljs-keyword"); + }); + + it("escapes code for an unknown language without throwing", () => { + const html = renderMarkdown("```nope\n<b>x</b>\n```"); + expect(html).toContain("<b>"); + }); + + it("sanitizes dangerous HTML", () => { + const html = renderMarkdown("Hi <script>alert(1)</script> there"); + expect(html).not.toContain("<script>"); + expect(html).toContain("Hi"); + }); + + it("balances dangling bold emphasis while streaming", () => { + expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>"); + }); + + it("does not balance delimiters when not streaming", () => { + expect(renderMarkdown("a **bold")).not.toContain("<strong>"); + }); + + it("wraps fenced code blocks with a copy button", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("code-block"); + expect(html).toContain("data-copy"); + expect(html).toContain("<pre>"); + }); + + it("does not add a copy button to inline code", () => { + const html = renderMarkdown("use `npm run dev` please"); + expect(html).not.toContain("data-copy"); + expect(html).toContain("<code>npm run dev</code>"); + }); + + it("returns an empty string for empty input", () => { + expect(renderMarkdown("")).toBe(""); + }); +}); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts new file mode 100644 index 0000000..3a6e5a6 --- /dev/null +++ b/src/features/markdown/logic/markdown.ts @@ -0,0 +1,165 @@ +/** + * Pure Markdown β sanitized-HTML renderer for assistant messages. + * + * Mirrors old Dispatch's stack (marked + marked-highlight + highlight.js + + * DOMPurify; GFM + line breaks; streaming delimiter-closing), but kept fully + * SYNCHRONOUS and pure: `input β output`, no effects, no `$effect`. Languages + * are a fixed "hot set" registered at module load (no lazy dynamic import), so a + * single `renderMarkdown(text)` call is deterministic and unit-testable. + * + * The only ambient dependency is DOMPurify, which sanitizes against the DOM β + * present in the browser and in the jsdom test env. + */ + +import DOMPurify from "dompurify"; +import type { LanguageFn } from "highlight.js"; +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import c from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import csharp from "highlight.js/lib/languages/csharp"; +import css from "highlight.js/lib/languages/css"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdownLang from "highlight.js/lib/languages/markdown"; +import php from "highlight.js/lib/languages/php"; +import plaintext from "highlight.js/lib/languages/plaintext"; +import python from "highlight.js/lib/languages/python"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import shell from "highlight.js/lib/languages/shell"; +import sql from "highlight.js/lib/languages/sql"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; +import { Marked } from "marked"; +import { markedHighlight } from "marked-highlight"; + +// Hot set: registered eagerly so common code blocks highlight on first paint. +const HOT_LANGUAGES: Record<string, LanguageFn> = { + bash, + c, + cpp, + csharp, + css, + go, + java, + javascript, + json, + markdown: markdownLang, + php, + plaintext, + python, + ruby, + rust, + shell, + sql, + typescript, + xml, + yaml, +}; +for (const [name, lang] of Object.entries(HOT_LANGUAGES)) { + hljs.registerLanguage(name, lang); +} + +// Normalize common fence aliases to canonical highlight.js names. +const ALIASES: Record<string, string> = { + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + py3: "python", + rb: "ruby", + sh: "bash", + zsh: "bash", + yml: "yaml", + "c++": "cpp", + cxx: "cpp", + "c#": "csharp", + cs: "csharp", + htm: "xml", + html: "xml", + svg: "xml", + md: "markdown", + mdx: "markdown", + golang: "go", + rs: "rust", +}; + +function normalizeLang(lang: string): string { + const lower = lang.toLowerCase().trim(); + return ALIASES[lower] ?? lower; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const md = new Marked( + markedHighlight({ + emptyLangClass: "hljs", + langPrefix: "hljs language-", + highlight(code: string, lang: string): string { + if (!lang) return escapeHtml(code); + const name = normalizeLang(lang); + if (!hljs.getLanguage(name)) return escapeHtml(code); + try { + return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; + } catch { + return escapeHtml(code); + } + }, + }), + { gfm: true, breaks: true }, +); + +/** + * While a message is still streaming, balance dangling fences / emphasis so the + * partial text renders cleanly instead of flashing raw markers. + */ +function closeOpenDelimiters(src: string): string { + let out = src; + const fenceCount = (out.match(/^```/gm) ?? []).length; + if (fenceCount % 2 !== 0) out += "\n```"; + const boldCount = (out.match(/\*\*/g) ?? []).length; + if (boldCount % 2 !== 0) out += "**"; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + if (inlineCode % 2 !== 0) out += "`"; + return out; +} + +// Wrap each fenced code block (`<pre>β¦</pre>`) in a positioned container with a +// copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and +// escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. +// `data-copy` is the delegation hook the component listens for; DOMPurify keeps +// `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched. +const COPY_BUTTON = + '<button type="button" data-copy aria-label="Copy code"' + + ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>'; + +function addCopyButtons(html: string): string { + return html + .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`) + .replace(/<\/pre>/g, "</pre></div>"); +} + +/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ +export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { + const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; + try { + const raw = md.parse(src) as string; + return DOMPurify.sanitize(addCopyButtons(raw)); + } catch { + return ""; + } +} diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte new file mode 100644 index 0000000..b828ab9 --- /dev/null +++ b/src/features/markdown/ui/Markdown.svelte @@ -0,0 +1,58 @@ +<script lang="ts"> + import { renderMarkdown } from "../logic/markdown"; + + let { + text, + streaming = false, + }: { + text: string; + /** Balance dangling delimiters while the message is still generating. */ + streaming?: boolean; + } = $props(); + + // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. + const html = $derived(renderMarkdown(text, { streaming })); + + let container: HTMLElement; + + // One delegated listener on the stable container handles every code block's + // copy button β including blocks re-created when `html` changes (streaming), + // since the listener lives on the container, not the buttons. Clipboard is the + // edge effect; absent (insecure context) β no-op. + $effect(() => { + const el = container; + if (el === undefined) return; + + const onClick = (event: Event): void => { + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>("[data-copy]"); + if (button === null) return; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied β leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); +</script> + +<div class="markdown-body" bind:this={container}> + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html html} +</div> diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts new file mode 100644 index 0000000..e34a4af --- /dev/null +++ b/src/features/markdown/ui/markdown.test.ts @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import Markdown from "./Markdown.svelte"; + +describe("Markdown", () => { + it("renders markdown into a .markdown-body container", () => { + const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); + + expect(container.querySelector(".markdown-body")).not.toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.querySelector("strong")?.textContent).toBe("hi"); + }); + + it("strips dangerous markup", () => { + const { container } = render(Markdown, { + props: { text: "before <script>alert(1)</script> after" }, + }); + + expect(container.querySelector("script")).toBeNull(); + expect(container.textContent).toContain("before"); + }); + + it("renders a copy button on a code block that copies the code to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + const { container } = render(Markdown, { + props: { text: "```js\nconst x = 1;\n```" }, + }); + + const button = container.querySelector<HTMLElement>("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); + + await fireEvent.click(button); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); + }); +}); diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index a5727b4..be296a7 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -57,6 +57,47 @@ describe("planSurface", () => { expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); }); + it("maps a number field to a NumberFieldView, carrying optional hints", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + ]); + }); + + it("omits absent number hints (no max key when undefined)", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + action: { actionId: "set" }, + }), + ); + const field = plan.fields[0]; + expect(field).not.toHaveProperty("max"); + expect(field).not.toHaveProperty("step"); + expect(field).not.toHaveProperty("unit"); + }); + it("maps a button field to a ButtonFieldView", () => { const plan = planSurface( makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts index 769f9f9..89088c3 100644 --- a/src/features/surface-host/logic/plan.ts +++ b/src/features/surface-host/logic/plan.ts @@ -1,7 +1,21 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { FieldView, RenderGroup, StatFieldView, SurfaceRenderPlan } from "./types"; +import type { + FieldView, + NumberFieldView, + RenderGroup, + StatFieldView, + SurfaceRenderPlan, +} from "./types"; -const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button", "custom"]); +const KNOWN_KINDS = new Set([ + "toggle", + "progress", + "selector", + "stat", + "number", + "button", + "custom", +]); /** * Validate and normalise a SurfaceSpec into a renderable plan. @@ -46,6 +60,21 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { value: field.value, }); break; + case "number": { + // Carry optional hints only when present (exactOptionalPropertyTypes). + const view: NumberFieldView = { + kind: "number", + label: field.label, + value: field.value, + action: field.action, + ...(field.min !== undefined ? { min: field.min } : {}), + ...(field.max !== undefined ? { max: field.max } : {}), + ...(field.step !== undefined ? { step: field.step } : {}), + ...(field.unit !== undefined ? { unit: field.unit } : {}), + }; + fields.push(view); + break; + } case "button": fields.push({ kind: "button", diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts index d1888a2..23f8757 100644 --- a/src/features/surface-host/logic/types.ts +++ b/src/features/surface-host/logic/types.ts @@ -31,6 +31,22 @@ export interface StatFieldView { readonly value: string; } +/** + * Normalised view-model for a number field β the free-value counterpart to + * selector. `min`/`max`/`step`/`unit` are optional semantic hints (absent when + * the spec omits them). The renderer posts the new number as the action payload. + */ +export interface NumberFieldView { + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; +} + /** Normalised view-model for a button field. */ export interface ButtonFieldView { readonly kind: "button"; @@ -55,6 +71,7 @@ export type FieldView = | ProgressFieldView | SelectorFieldView | StatFieldView + | NumberFieldView | ButtonFieldView | CustomFieldView; diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte new file mode 100644 index 0000000..0f3323d --- /dev/null +++ b/src/features/surface-host/ui/Number.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { NumberFieldView } from "../logic/types"; + + let { + field, + surfaceId, + onInvoke, + }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); + + // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric + // input (the backend also floors/validates); send the new number as payload. + function commit(event: Event) { + const target = event.target as HTMLInputElement; + const next = target.valueAsNumber; + if (Number.isNaN(next)) return; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: next, + }); + } +</script> + +<label class="flex items-center justify-between gap-2 text-sm"> + <span>{field.label}</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-24" + value={field.value} + min={field.min} + max={field.max} + step={field.step} + onchange={commit} + /> + {#if field.unit} + <span class="opacity-60">{field.unit}</span> + {/if} + </span> +</label> diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index 5210e8c..24be8b8 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -2,6 +2,7 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { groupRenderFields, planSurface } from "../logic/plan"; import Button from "./Button.svelte"; + import Number from "./Number.svelte"; import Progress from "./Progress.svelte"; import Selector from "./Selector.svelte"; import StatTable from "./StatTable.svelte"; @@ -30,6 +31,8 @@ <Progress field={group.field} /> {:else if group.field.kind === "selector"} <Selector field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "number"} + <Number field={group.field} surfaceId={spec.id} {onInvoke} /> {:else if group.field.kind === "button"} <Button field={group.field} surfaceId={spec.id} {onInvoke} /> {:else if group.field.kind === "custom"} |
