summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/package-agent.md80
-rw-r--r--.dispatch/rules/daisyui.md1967
-rw-r--r--.dispatch/rules/frontend-inject-transport.md7
-rw-r--r--.dispatch/rules/frontend-interpreter-generic.md8
-rw-r--r--.dispatch/rules/frontend-no-ambient-state.md7
-rw-r--r--.dispatch/rules/frontend-pure-core.md7
-rw-r--r--.dispatch/rules/frontend-styling.md12
-rw-r--r--.dispatch/transport-contract.reference.md1114
-rw-r--r--.dispatch/ui-contract.reference.md127
-rw-r--r--.dispatch/wire.reference.md555
10 files changed, 3683 insertions, 201 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md
deleted file mode 100644
index 73e960c..0000000
--- a/.dispatch/package-agent.md
+++ /dev/null
@@ -1,80 +0,0 @@
-<!-- ORCHESTRATOR-ONLY meta (see ORCHESTRATOR.md): every FE summon is assembled as
- package-agent.md + the inlined .dispatch/rules/* + the per-summon TASK block.
- This is the base for ALL FE owner-agents; nothing here is restated per summon. -->
-
-# Frontend Owner-Agent — Brief
-
-You are the **sole owner-agent for exactly ONE unit** — a single feature module /
-directory under `src/`. Your unit + job are in the **TASK** at the end. You build
-it, test it, and write a report — nothing else. If no single unit is named, stop.
-
-## Hard guardrails (NON-NEGOTIABLE)
-- **Single-writer, directory-scoped.** Read/create/edit any file inside your unit's
- directory. Never create or edit anything OUTSIDE it — not another feature, not
- `src/app/` (the composition root), not root config (`package.json`,
- `tsconfig.json`, `vite.config.ts`, `biome.json`), not the harness, not the
- backend repo (`../arch-rewrite`).
-- **Need a change outside your unit?** Do NOT make it — write a CHANGE-REQUEST in
- your report (a sibling's public export, a backend contract, root config,
- composition wiring). The orchestrator dispatches it.
-- **No workspace/dep wiring.** Don't `bun install` or edit root config; list a new
- dep / wiring need as a CR.
-- **No git** (no commits, branches, pushes, resets).
-
-## What you may read (visibility)
-- **Your own unit:** every file, freely.
-- **The contracts you consume:** reproduced IN-REPO under `.dispatch/*.reference.md` — read THOSE:
- - `.dispatch/ui-contract.reference.md` — `@dispatch/ui-contract` (surfaces + surface WS protocol).
- - `.dispatch/wire.reference.md` — `@dispatch/wire` (`Chunk`/`StoredChunk`+`seq`/`ChatMessage`/
- `AgentEvent`/`TurnSealedEvent`/`Usage` — the chat wire types).
- - `.dispatch/transport-contract.reference.md` — `@dispatch/transport-contract` (HTTP endpoints +
- `ChatRequest`/`ModelsResponse`/`ConversationHistoryResponse` + WS chat ops + the unified
- `WsClientMessage`/`WsServerMessage` unions).
-
- Your code imports `@dispatch/ui-contract` / `@dispatch/wire` / `@dispatch/transport-contract`
- normally, but **do NOT read `node_modules/@dispatch/*`** — they symlink to the backend repo
- (OUTSIDE this repo) and a headless permission prompt will HANG the run (see "Headless read
- boundary").
-- **Sibling units — PUBLIC SURFACE only:** their `index.ts` exports. Don't read
- their internals (needing them ⇒ the contract is incomplete → report a CR).
-
-## Headless read boundary (you run non-interactively)
-You run HEADLESS: a Read of any file OUTSIDE this repo (`dispatch-web/`) triggers a
-permission prompt that CANNOT be answered → the run HANGS until aborted. Use Read/Edit ONLY
-within `dispatch-web/`. If you believe you need a file outside your scope, do NOT attempt
-the read — STOP and write the need in your report, then end.
-
-## Engineering standard (the inlined `.dispatch/rules/*` govern; in brief)
-- **Pure core / injected shell.** Decision logic is `input → output`: zero DOM,
- zero `fetch`/WS, zero Svelte. Put it in a `.ts` module (e.g. `logic/`) that tests
- with NO mounting and NO mocks. Effects are INJECTED (props or an `adapter/`).
-- **Svelte-thin.** `.svelte` files wire props/events to pure logic + render; no
- business logic. (biome lints `.ts`/`.js` only; `svelte-check` owns `.svelte`.)
-- **No ambient state.** Own state explicitly; runes wrap the pure reducer;
- subscriptions are disposed on unmount.
-- **Tests, asymmetric.** Pure logic → vitest with ZERO internal mocks (never
- `vi.mock` of our own modules). Components → a few `@testing-library/svelte`
- tests; don't chase coverage there, don't mock siblings. Faking the OUTERMOST
- edge (a fake socket/fetch/clock) is the only allowed mock.
-- **Isolation over DRY.** Self-contained over a shared helper wired between
- features. The only shared surfaces are the imported contracts.
-- **Strict TS:** respect `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`.
-
-## Verify before finishing — YOUR UNIT IN ISOLATION
-Sibling units may be built IN PARALLEL, so do NOT judge yourself by the whole project.
-`svelte-check` is whole-project: if it reports errors in files OUTSIDE your unit's directory,
-that is concurrent work-in-progress — IGNORE it and ensure only YOUR files (plus any existing
-consumer you must not break) are error-free. The SCOPED checks below are your authoritative
-signals. Run them, and paste the output into your report:
-- `bunx svelte-check --tsconfig ./tsconfig.json` → 0 errors in YOUR files
-- `bunx vitest run src/<your-dir>` → all pass (count goes up)
-- `bunx biome check src/<your-dir>` → clean
-The orchestrator runs the authoritative full `typecheck`/`test`/`check`/`build`.
-
-## Report (REQUIRED) → `reports/<your-unit>.md`
-1. Files created/changed. 2. Public surface you expose (exported types/functions/
-components). 3. New test names + the isolated-verify output. 4. Change-requests
-(sibling export, backend contract, root config, composition wiring) — explicit and
-actionable.
-
-Your specific **TASK** follows at the end of this prompt.
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/rules/frontend-inject-transport.md b/.dispatch/rules/frontend-inject-transport.md
deleted file mode 100644
index fbe83d7..0000000
--- a/.dispatch/rules/frontend-inject-transport.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Rule: inject the transport; parsers are pure
-
-The WS/NDJSON framing + parsing is a PURE function (bytes/messages → typed events);
-the socket/fetch is INJECTED. Test the parser with crafted chunk inputs (and
-trace-replay-style fixtures), never a live connection. The op-protocol core is a
-pure state machine: `reduce(intent, incoming) → { viewModel, outgoingCommands }`;
-the carrier (WebSocket) is the injected shell.
diff --git a/.dispatch/rules/frontend-interpreter-generic.md b/.dispatch/rules/frontend-interpreter-generic.md
deleted file mode 100644
index 557f2d4..0000000
--- a/.dispatch/rules/frontend-interpreter-generic.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Rule: the surface interpreter is generic
-
-The surface interpreter switches on field KINDS (toggle/progress/selector/stat/
-button/custom), NEVER on a surface id. An `if (surface.id === "...")` imports a
-feature's identity into the platform and breaks isolation (guardrail 1). An
-unknown field `kind` or a `custom` `rendererId` with no registered renderer →
-GRACEFUL SKIP, never a crash. Render from the spec; the backend owns what a
-surface contains.
diff --git a/.dispatch/rules/frontend-no-ambient-state.md b/.dispatch/rules/frontend-no-ambient-state.md
deleted file mode 100644
index 663bf1a..0000000
--- a/.dispatch/rules/frontend-no-ambient-state.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Rule: no ambient state (frontend)
-
-State is owned per-unit and passed explicitly. NO module-global mutable store
-reached from everywhere — that is the old FE's "tools leak across tabs" /
-"model resets on tab switch" bug class. Svelte runes (`$state`) are a THIN
-reactive wrapper over a pure reducer, never the home of logic. Subscriptions are
-owned and disposed on unmount (no orphaned or duplicate subscriptions).
diff --git a/.dispatch/rules/frontend-pure-core.md b/.dispatch/rules/frontend-pure-core.md
deleted file mode 100644
index fa7bc2e..0000000
--- a/.dispatch/rules/frontend-pure-core.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Rule: pure core / injected shell (frontend)
-
-Decision logic — reducers, view-models, formatters, parsers — is pure
-(input → output): NO DOM, NO `fetch`/WebSocket, NO Svelte import. Put it in a
-`.ts` module that tests with zero mounting and zero mocks. Effects (socket, fetch,
-IndexedDB, clock) are INJECTED at the edges (props or an adapter). This is for
-testability, not purity dogma — stop where it would only add ceremony.
diff --git a/.dispatch/rules/frontend-styling.md b/.dispatch/rules/frontend-styling.md
deleted file mode 100644
index e123619..0000000
--- a/.dispatch/rules/frontend-styling.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# Rule: styling is DaisyUI v5 + the dracula theme (UI units only)
-
-The global stylesheet already enables **DaisyUI v5** with the **dracula** theme
-(`data-theme` on `<html>`). Do NOT add or change theme config, re-import Tailwind/DaisyUI,
-or hand-roll a design system — just apply DaisyUI / Tailwind utility classes in your
-`.svelte` files (e.g. `select`, `textarea`, `btn btn-primary`, `tabs`, `chat chat-start` /
-`chat-end` + `chat-bubble`).
-
-Keep components THIN: a `.svelte` file wires props/events to pure logic and applies classes
-— it holds NO business logic (that stays in `logic/` / the reducer). Render plain semantic
-HTML decorated with classes. biome lints `.ts`/`.js` only; `.svelte` correctness is
-`svelte-check`'s job.
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 3a7a59c..0582d94 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -1,44 +1,143 @@
# `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules)
> MIRRORS the backend's `@dispatch/transport-contract` package source so headless FE agents can read
-> the HTTP + WebSocket wire shapes WITHOUT following the `file:` dep symlink out of this repo (which
-> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
-> this file is for READING only.
+> the transport types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a
+> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for
+> READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes.
-> Depends on `@dispatch/wire` (see `wire.reference.md`) + `@dispatch/ui-contract`
-> (see `ui-contract.reference.md`).
-
-## Endpoints (backend, confirmed live — CORS wildcard `*`, HTTP port 24203, WS port 24205)
-
-- `POST /chat` — body `ChatRequest` (JSON); response NDJSON stream, one `AgentEvent` per line;
- resolved id also in `X-Conversation-Id` header.
-- `GET /models` — `ModelsResponse`.
-- `GET /conversations/:id?sinceSeq=<n>` — `ConversationHistoryResponse`: RAW, append-order,
- seq-ordered slice with `seq > n` (NOT reconciled — dangling tool-calls returned as-is).
- `latestSeq` = last chunk's `seq`, or the requested `sinceSeq` when caught up (empty `chunks`).
-- 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`**
- (seq lives only on `StoredChunk`, obtained via the `sinceSeq` sync after `turn-sealed`).
-- DEFERRED (not built; do not depend on): `GET /conversations` (list), `POST /conversations/:id/cancel`.
-
-```ts
+> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever
+> it changes.
+>
+> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains
+> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry`
+> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is
+> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced
+> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when
+> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` —
+> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user
+> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown
+> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when
+> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded;
+> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer,
+> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded.
+> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream
+> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3.
+>
+> **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the
+> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`),
+> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`),
+> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The
+> `concurrency` extension tracks/limits in-flight token-generating requests per provider with
+> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and
+> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j.
+> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface.
+> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]`
+> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/
+> empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the
+> model natively accepts images; absent → the server's vision handoff transcribes images to text before the
+> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here).
+>
+> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image`
+> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it
+> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the
+> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED
+> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to
+> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image
+> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: <description>` text
+> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global
+> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`),
+> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable
+> compaction; `compactionModel?` `<key>/<model>` or null = auto). See `backend-handoff.md` §2j.
+>
+> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now
+> compact relative HTTP paths (`/images/<conversationId>/<uuid>.png`) served by the new
+> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data
+> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`)
+> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in
+> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the
+> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j.
+>
+> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the
+> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`),
+> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`),
+> `SetConversationComputerRequest` + `ConversationComputerResponse`
+> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest`
+> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/
+> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so
+> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See
+> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave —
+> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.)
+>
+> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds
+> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint
+> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns
+> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`,
+> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`,
+> and optional `configSource`. Also adds the previously-missing `configSource` field to
+> `LspServerInfo`. See `frontend-mcp-status-handoff.md`.
+>
+> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds
+> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and
+> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and
+> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation
+> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`,
+> and dynamic `file:<path>`; conditional blocks use `[if]`, `[else]`, `[endif]`. See
+> `frontend-system-prompt-handoff.md`.
+
/**
* Transport contract — the typed description of Dispatch's client–server API
- * (HTTP + WebSocket). Types-only (zero runtime). Each side owns its own
- * (de)serialization — the contract is the SHAPES, not the codec.
+ * (HTTP + WebSocket).
+ *
+ * This package is types-only (zero runtime). It is the single shared surface
+ * every client imports to know how to talk to the backend — the CLI, the web
+ * frontend (in its own repo), any third-party client — and the transport-http /
+ * transport-ws servers import to know what they must accept and emit.
*
- * The WebSocket carries BOTH chat ops (here) and surface ops (in
+ * Each side owns its OWN (de)serialization: there is deliberately no shared
+ * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES,
+ * not the codec. The streaming response payload is the kernel's `AgentEvent`
+ * union, re-exported here so a client has one import for the whole wire.
+ *
+ * The WebSocket carries BOTH chat ops (defined here) and surface ops (defined in
* `@dispatch/ui-contract`) over one connection; the unified `WsClientMessage` /
* `WsServerMessage` unions below compose them. Chat ops are new, non-colliding
- * `type` variants (`chat.*`) — the shipped surface protocol is unchanged.
+ * `type` variants — there is no channel wrapper, so the shipped surface protocol
+ * is unchanged.
*/
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import type {
+ AgentEvent,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageInput,
+ QueuedMessage,
+ ReasoningEffort,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
+} from "@dispatch/wire";
-export type { AgentEvent, StoredChunk } from "@dispatch/wire";
+export type {
+ AgentEvent,
+ CompactionResult,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageChunk,
+ ImageInput,
+ QueuedMessage,
+ ReasoningEffort,
+ StepMetrics,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
+} from "@dispatch/wire";
/**
* Request body for `POST /chat` (sent as JSON).
@@ -48,49 +147,557 @@ export type { AgentEvent, StoredChunk } from "@dispatch/wire";
* response header (useful when `conversationId` was omitted).
*/
export interface ChatRequest {
- /** The conversation to continue. Omit to start fresh — server mints an id (X-Conversation-Id). */
+ /**
+ * The conversation to continue. Omit to start a fresh conversation — the
+ * server mints an id and returns it via the `X-Conversation-Id` header.
+ */
readonly conversationId?: string;
+
/** The user's message text for this turn. */
readonly message: string;
- /** Model name in `<credentialName>/<model>` form (one of `GET /models`). Omit = server default. */
+
+ /**
+ * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's
+ * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://`
+ * URL. The server converts these to `image` chunks on the persisted user
+ * message. For a VISION-capable model (e.g. kimi), the images are passed
+ * through to the provider natively. For a NON-vision model (e.g. glm-5.2),
+ * the server's vision handoff transcribes each image to a text description
+ * (via a vision-capable model) and feeds that text instead — so a text-only
+ * model can still reason about the image's contents. Optional — omit for a
+ * text-only turn (backward compatible). Validation: non-array `images` →
+ * 400; an image without `url` → 400; empty `url` → 400. An empty array is
+ * accepted and treated as absent.
+ */
+ readonly images?: readonly ImageInput[];
+
+ /**
+ * The model to use, as a model name in `<credentialName>/<model>` form — one
+ * of the exact strings returned by `GET /models`. Omit to use the server's
+ * default credential + model.
+ */
readonly model?: string;
- /** Working directory for this turn's tool execution. Defaults server-side. Not part of the prompt. */
+
+ /**
+ * Working directory for this turn's tool execution. Defaults server-side when
+ * omitted. Forwarded to tools for path resolution; never part of the model
+ * prompt (so it does not affect prompt caching).
+ */
readonly cwd?: string;
+
+ /**
+ * Reasoning-effort override for THIS turn only (does not persist). When
+ * omitted, the server resolves the conversation's persisted value, falling
+ * back to `"high"`. Must be one of the `ReasoningEffort` levels; an
+ * unrecognized value → HTTP 400 `{ error }`.
+ */
+ readonly reasoningEffort?: ReasoningEffort;
+
+ /**
+ * The workspace to assign this conversation to. Omit for `"default"`.
+ * If the workspace doesn't exist yet, it is auto-created (title = id,
+ * defaultCwd = null).
+ */
+ readonly workspaceId?: string;
+
+ /**
+ * The computer to run this turn's tools on — an SSH config `Host` alias
+ * (one of the `alias` values returned by `GET /computers`). Omit to inherit
+ * the resolved chain: per-conversation `computerId` → the workspace's
+ * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is
+ * a per-turn tool-execution target forwarded to tools and never part of the
+ * model prompt (so it does not affect prompt caching). Mirrors `cwd`.
+ */
+ readonly computerId?: string;
}
/**
- * Response body for `GET /models` — the model catalog. Each entry is a model
- * name in `<credentialName>/<model>` form (exactly `ChatRequest.model`).
+ * Response body for `GET /models` — the model catalog.
+ *
+ * Each entry in `models` is a model name in `<credentialName>/<model>` form:
+ * exactly the string a client passes back as `ChatRequest.model`.
+ * `modelInfo` is an optional map from the same `<credentialName>/<model>` key
+ * to model metadata (e.g. `contextWindow`). Additive — clients that only
+ * read `models` are unaffected.
*/
export interface ModelsResponse {
readonly models: readonly string[];
+ readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
+}
+
+/** Per-model metadata returned alongside the model catalog. */
+export interface ModelMetadata {
+ readonly contextWindow?: number;
+ /**
+ * Whether this model can natively accept image input (vision/multimodal).
+ * When `true`, image chunks in a user message are passed through to the
+ * provider. When `false`/absent, the server's vision handoff transcribes
+ * images to text before the model sees them. A client may use this to show
+ * a vision badge in the model picker. Optional — absent when unknown.
+ */
+ readonly vision?: boolean;
}
/**
- * Response body for `GET /conversations/:id?sinceSeq=<n>` — the incremental
- * read-side history endpoint a long-lived client uses to (re)hydrate cheaply.
+ * Response body for
+ * `GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` — the
+ * incremental read-side history endpoint a long-lived client uses to
+ * (re)hydrate a conversation cheaply. All three query params are OPTIONAL and
+ * combine as one SELECTION + one WINDOW:
+ *
+ * - **Selection** — `sinceSeq` (exclusive lower bound, `seq > n`; omitted/0 =
+ * from the start) and `beforeSeq` (exclusive upper bound, `seq < s`; omitted
+ * = to the end). Together: `n < seq < s`.
+ * - **Window** — `limit=<k>` returns only the NEWEST `k` chunks of the
+ * selection (the response stays ASCENDING by seq). A selection with ≤ `k`
+ * chunks is returned whole. `limit` omitted = the full selection — exactly
+ * the pre-windowing behavior, so existing clients are unchanged.
+ * - `limit` and `beforeSeq` must be POSITIVE integers (`sinceSeq` may be 0);
+ * malformed, zero, or negative values → HTTP 400 `{ error }`.
+ *
+ * Intended client flows: fresh load = `?sinceSeq=0&limit=<k>` (newest window);
+ * tail sync = `?sinceSeq=<cursor>` (no limit); page older history in =
+ * `?beforeSeq=<oldestLoadedSeq>&limit=<k>`.
*
- * `chunks` is the RAW, append-order, seq-ordered slice with `seq > sinceSeq`
- * (or the whole log when `sinceSeq` is omitted/0). NOT reconciled: a dangling
- * tool-call is returned as-is. `latestSeq` is the `seq` of the LAST chunk, or —
- * when the slice is empty (caught up) — the requested `sinceSeq` (0 for a full
- * read of an empty conversation). After applying, the client's new cursor is
- * always `latestSeq`; empty `chunks` means "nothing new past your cursor".
+ * Seq numbering is **1-based and gap-free** (a CONTRACTUAL GUARANTEE — see
+ * `StoredChunk` in `@dispatch/wire`): a client can derive "older chunks exist"
+ * purely from `oldestLoaded.seq > 1`; there is deliberately no
+ * `earliestSeq`/`hasOlder` response field.
+ *
+ * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log
+ * selected + windowed as above. It is NOT reconciled: a dangling tool-call is
+ * returned as-is (rendered as an interrupted call). Reconciliation is a
+ * turn-path concern — the server repairs history only when it feeds a provider,
+ * never on this read path — which is what preserves the per-chunk `seq` cursor
+ * invariant (a synthesized repair chunk would have no seq).
+ *
+ * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the
+ * slice is empty (the client is already caught up) — the requested `sinceSeq`
+ * (0 for a full read of an empty conversation). So after applying the response a
+ * client's new cursor is always `latestSeq`, and an empty `chunks` means
+ * "nothing new past your cursor". CAVEAT (windowed reads): `latestSeq` is a
+ * TAIL-sync cursor — on a `beforeSeq` backfill page (or any `limit`ed read that
+ * did not reach the log's true tail) it describes the returned window, NOT the
+ * conversation's high-water mark, so a client must not regress its sync cursor
+ * from a backfill response. (A true server-side high-water mark independent of
+ * the filter is deferred until a consumer needs it — it would require widening
+ * the store contract.)
*/
export interface ConversationHistoryResponse {
readonly chunks: readonly StoredChunk[];
readonly latestSeq: number;
}
+/**
+ * Response body for `GET /conversations/:id/metrics` — the persisted per-turn
+ * (and per-step) token + timing metrics for a conversation, for a client
+ * reopening a past conversation to render historical usage/latency.
+ *
+ * This is a SEPARATE axis from the two other read concerns and is deliberately
+ * its own endpoint: the live `usage`/`step-complete`/`done` events are transient
+ * (not persisted), and `ConversationHistoryResponse` carries seq-cursor chunk
+ * CONTENT. Metrics are keyed per TURN (not per chunk) and so are not seq-filtered
+ * — hence a sibling route rather than a field on the history response.
+ *
+ * `turns` is every SEALED turn's `TurnMetrics` in turn order. A turn appears only
+ * after its metrics were persisted (post-seal); an in-flight or unsealed turn is
+ * absent until then.
+ */
+export interface ConversationMetricsResponse {
+ readonly turns: readonly TurnMetrics[];
+}
+
+export interface ConversationStatusResponse {
+ readonly conversationId: string;
+ /** True if the orchestrator has an in-memory active turn for this conversation. */
+ readonly isActive: boolean;
+ /** The persisted lifecycle status from the conversation store. */
+ readonly status: ConversationStatus;
+}
+
+/** The aggregation window for `GET /metrics/throughput`. */
+export type ThroughputPeriod = "day" | "week" | "month";
+
+/**
+ * One model's throughput over a period. `tokensPerSecond` is the TOKEN-WEIGHTED
+ * average — `Σ(output tokens) / Σ(generation seconds)` across the period's
+ * turns — so larger turns count proportionally more than smaller ones.
+ * Generation time is the model's pure decode time (it excludes tool-execution
+ * waits).
+ */
+export interface ThroughputModelStat {
+ /** The model name in `<credentialName>/<model>` form (as selected). */
+ readonly model: string;
+ /** Token-weighted average tokens/second over the period. */
+ readonly tokensPerSecond: number;
+ /** Total output tokens generated across the period's turns. */
+ readonly totalOutputTokens: number;
+ /** Total pure generation time across the period's turns, in milliseconds. */
+ readonly totalGenMs: number;
+ /** Number of turns that contributed. */
+ readonly turns: number;
+}
+
+/**
+ * Response body for
+ * `GET /metrics/throughput?period=day|week|month&date=<...>`.
+ *
+ * `date` is `YYYY-MM-DD` for day/week (week = the ISO Mon–Sun week containing
+ * that date) and `YYYY-MM` for month. Boundaries are computed in the server's
+ * local timezone; `start`/`end` are the resolved half-open `[start, end)` range
+ * in epoch-ms. `models` lists every model active in the window, sorted by
+ * `tokensPerSecond` descending.
+ */
+export interface ThroughputResponse {
+ readonly period: ThroughputPeriod;
+ readonly date: string;
+ /** Inclusive start of the window, epoch-ms. */
+ readonly start: number;
+ /** Exclusive end of the window, epoch-ms. */
+ readonly end: number;
+ readonly models: readonly ThroughputModelStat[];
+}
+
+// ─── Per-conversation working directory (cwd) ─────────────────────────────────
+
+/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */
+export interface CwdResponse {
+ readonly conversationId: string;
+ readonly cwd: string | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/cwd`.
+ *
+ * When `workspaceId` is provided, the conversation is assigned to that
+ * workspace BEFORE the cwd is persisted — so a subsequent
+ * `GET /conversations/:id/lsp` resolves a relative cwd against the
+ * workspace's `defaultCwd` (not the server default). Omit for unchanged
+ * workspace assignment (the conversation keeps its current workspace, or
+ * `"default"` if none).
+ */
+export interface SetCwdRequest {
+ readonly cwd: string;
+ readonly workspaceId?: string;
+}
+
+// ─── Per-conversation reasoning effort ────────────────────────────────────────
+
+/**
+ * Response of `GET /conversations/:id/reasoning-effort`. `reasoningEffort` is
+ * null when never set (the server then resolves turns at the default,
+ * `"high"`).
+ */
+export interface ReasoningEffortResponse {
+ readonly conversationId: string;
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/reasoning-effort` — persists the
+ * conversation's sticky reasoning-effort level (used for every later turn that
+ * does not carry a per-turn `ChatRequest.reasoningEffort` override). An
+ * unrecognized level → HTTP 400 `{ error }`.
+ */
+export interface SetReasoningEffortRequest {
+ readonly reasoningEffort: ReasoningEffort;
+}
+
+// ─── Per-conversation model persistence ───────────────────────────────────────
+
+/**
+ * Response of `GET /conversations/:id/model`. `model` is the persisted model
+ * name in `<credentialName>/<model>` form, or null when never set (the server
+ * then resolves turns using the default provider + model).
+ */
+export interface ModelResponse {
+ readonly conversationId: string;
+ readonly model: string | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/model` — persists the conversation's sticky
+ * model selection (used for every later turn that does not carry a per-turn
+ * `ChatRequest.model` override). Pass `null` to clear the persisted selection.
+ * An unrecognized model name is not validated here (the provider resolves it
+ * at turn time; an unknown model → turn error, not a 400).
+ */
+export interface SetModelRequest {
+ readonly model: string | null;
+}
+
+// ─── Conversation close (explicit tab close) ──────────────────────────────────
+
+/**
+ * Response of `POST /conversations/:id/close` (no request body).
+ *
+ * The EXPLICIT "the user closed this conversation's tab" affordance — distinct
+ * from a socket disconnect or `chat.unsubscribe`, which deliberately never touch
+ * the turn or the warming schedule. Closing:
+ * 1. aborts any in-flight turn (the kernel stops at the next event boundary,
+ * partial messages are persisted, and the turn SEALS normally with
+ * `finishReason: "aborted"` — watchers see `done` + `turn-sealed`), and
+ * 2. stops + disables cache-warming for the conversation (persisted OFF, so a
+ * reopened conversation stays opt-in).
+ * Idempotent: closing an idle or unknown conversation succeeds with
+ * `abortedTurn: false`.
+ */
+export interface CloseConversationResponse {
+ readonly conversationId: string;
+ /** True when an in-flight turn existed and was aborted by this close. */
+ readonly abortedTurn: boolean;
+}
+
+// ─── System prompt template ───────────────────────────────────────────────────
+
+/**
+ * Response of `GET /system-prompt` — the current global system prompt template.
+ *
+ * The template is a text string with variable placeholders (`[type:name]`) and
+ * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first
+ * turn or compaction), variables are resolved against the conversation's cwd
+ * and system state. The resolved system prompt is persisted per conversation
+ * and reused on all subsequent turns (cache-safe — no per-turn reconstruction).
+ */
+export interface SystemPromptTemplateResponse {
+ /** The template text (may be empty — then no system prompt is sent). */
+ readonly template: string;
+}
+
+/**
+ * Body of `PUT /system-prompt` — set the global system prompt template.
+ *
+ * Changing the template does NOT affect existing conversations until they are
+ * compacted (the persisted resolved system prompt is stable). New
+ * conversations use the new template on their first turn.
+ */
+export interface SetSystemPromptTemplateRequest {
+ readonly template: string;
+}
+
+/**
+ * One available variable for the system prompt template, as reported by
+ * `GET /system-prompt/variables` so the frontend can render the variable
+ * selector buttons.
+ */
+export interface SystemPromptVariable {
+ /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */
+ readonly type: string;
+ /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */
+ readonly name: string;
+ /** Human-readable description of what the variable resolves to. */
+ readonly description: string;
+ /**
+ * When `true`, any name is valid for this type (e.g. `file:<path>` accepts
+ * any file path). The frontend should allow free-text input for the name.
+ */
+ readonly dynamic?: boolean;
+}
+
+/** Response of `GET /system-prompt/variables`. */
+export interface SystemPromptVariablesResponse {
+ readonly variables: readonly SystemPromptVariable[];
+}
+
+// ─── Vision settings (global) ───────────────────────────────────────────────
+
+/**
+ * Response of `GET /settings/vision` — the global vision configuration shared
+ * across all conversations and vision models.
+ */
+export interface VisionSettingsResponse {
+ /** Max native images per turn (default 10); 0 disables image compaction. */
+ readonly imageLimit: number;
+ /** Which model transcribes old images (null = auto-select a vision model). */
+ readonly compactionModel: string | null;
+}
+
+/** Body of `PUT /settings/vision` — a partial update. */
+export interface SetVisionSettingsRequest {
+ /** Non-negative integer (0 = disable compaction). */
+ readonly imageLimit?: number;
+ /** A model name (`<key>/<model>`) or null (auto). */
+ readonly compactionModel?: string | null;
+}
+
+// ─── Message queue (steering) ─────────────────────────────────────────────────
+
+/**
+ * Request body for `POST /conversations/:id/queue` — enqueue a user message
+ * onto a conversation's message queue for mid-turn steering delivery.
+ *
+ * When a turn is ACTIVE for the conversation, the message is appended to the
+ * queue (the message-queue extension's per-conversation SURFACE updates) and
+ * delivered at the next tool-result boundary as a steering message the model
+ * sees alongside the tool results (a `steering` `AgentEvent` is emitted). When
+ * NO turn is active, enqueuing instead STARTS a new turn with the message as its
+ * opening prompt (equivalent to `POST /chat`) — so a fire-and-forget enqueue
+ * works regardless of generation state. The resolved queue + whether a turn was
+ * started are returned in `QueueResponse`.
+ *
+ * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise.
+ */
+export interface QueueRequest {
+ readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
+}
+
+/**
+ * Response body for `POST /conversations/:id/queue` — the conversation's queue
+ * snapshot AFTER the enqueue, so a client renders the queue from this alone.
+ * `conversationId` echoes the path. `startedTurn` is true when no turn was
+ * active and the enqueue started a new turn (the message is now the turn's
+ * opening prompt, not a queued steering message); the turn's events stream on
+ * the chat channel as usual.
+ */
+export interface QueueResponse {
+ readonly conversationId: string;
+ readonly startedTurn: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
+// ─── Per-conversation LSP status ──────────────────────────────────────────────
+
+/** The connection state of a single language server for a workspace. */
+export type LspServerState = "connected" | "starting" | "error" | "not-started";
+
+/** One language server's status as reported to the frontend. */
+export interface LspServerInfo {
+ /** Stable server id, e.g. "typescript", "luau-lsp". */
+ readonly id: string;
+ /** Human-readable display name. */
+ readonly name: string;
+ /** The resolved workspace root the server is (or would be) rooted at (absolute). */
+ readonly root: string;
+ /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */
+ readonly extensions: readonly string[];
+ /** Current connection state. */
+ readonly state: LspServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /**
+ * Which config source this server was resolved from: `".dispatch/lsp.json"`,
+ * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted
+ * when not yet resolved. Surfaces config-shadow debugging to the status caller
+ * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`).
+ */
+ readonly configSource?: string;
+}
+
+/** Response of `GET /conversations/:id/lsp`. */
+export interface LspStatusResponse {
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the LSP connects on, or `null` when no
+ * cwd has been set for the conversation (then `servers` is empty). When
+ * non-null, this is the effective cwd — a relative persisted cwd resolved
+ * against the conversation's workspace `defaultCwd`.
+ */
+ readonly cwd: string | null;
+ /** The language servers configured for `cwd` and their live state. */
+ readonly servers: readonly LspServerInfo[];
+}
+
+// ─── MCP status ──────────────────────────────────────────────────────
+
+export type McpServerState = "connecting" | "connected" | "error" | "disconnected";
+
+/** One MCP server's status as reported to the frontend. */
+export interface McpServerInfo {
+ /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */
+ readonly id: string;
+ /** Current connection state. */
+ readonly state: McpServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /** Number of tools discovered from this server. */
+ readonly toolCount: number;
+ /** Which config source this server was resolved from. */
+ readonly configSource?: string;
+}
+
+/** Response of `GET /conversations/:id/mcp`. */
+export interface McpStatusResponse {
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the MCP servers are configured for, or
+ * `null` when no cwd has been set for the conversation (then `servers` is
+ * empty). Mirrors the LSP status endpoint behavior.
+ */
+ readonly cwd: string | null;
+ /** The MCP servers configured for `cwd` and their live state. */
+ readonly servers: readonly McpServerInfo[];
+}
+
+/**
+ * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING
+ * request for a conversation (e.g. a frontend "warm now" button, or fast tests
+ * that don't want to wait for the automatic warming timer).
+ *
+ * The warm replays the conversation's existing prefix to the provider to refresh
+ * its prompt cache; it is NEVER persisted and NEVER streamed (no `AgentEvent`s).
+ * Pass the same `model`/`cwd` the conversation chats with so the warm request's
+ * prefix is byte-identical to a real turn (which is what makes the cache hit).
+ */
+export interface WarmRequest {
+ /** The conversation whose prompt cache to warm. */
+ readonly conversationId: string;
+
+ /**
+ * The model name in `<credentialName>/<model>` form the conversation uses, so
+ * the warm resolves the same provider + prefix. Omit to use the server default.
+ */
+ readonly model?: string;
+
+ /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */
+ readonly cwd?: string;
+}
+
+/**
+ * Response body for `POST /chat/warm` (HTTP 200). The warm request's usage —
+ * never folded into the conversation's real usage. A client surfaces `cachePct`
+ * as the "last warming" cache-hit indicator.
+ *
+ * When warming cannot run because the conversation is currently generating, the
+ * server responds `409` with `{ error }` instead of this body.
+ */
+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%** (the whole prefix was still
+ * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite
+ * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's
+ * tiny fresh probe makes ~equal, but which on a real turn reflects new content).
+ */
+ 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
-// never collide with surface ones.
+// (`@dispatch/ui-contract`). The unified unions at the bottom compose both. Chat
+// `type`s are namespaced (`chat.*`) so they never collide with surface ones.
/**
- * Client → server: start or continue a turn over the WS connection. Same fields
- * as the HTTP `ChatRequest`; omit `conversationId` to start fresh — the resolved
- * id arrives on the streamed `AgentEvent`s (each carries `conversationId`).
+ * Client → server: start or continue a turn over the WS connection. Carries the
+ * same fields as the HTTP `ChatRequest` (so one shape drives both transports);
+ * omit `conversationId` to start fresh — the resolved id arrives on the streamed
+ * `AgentEvent`s (each carries `conversationId`).
*/
export interface ChatSendMessage extends ChatRequest {
readonly type: "chat.send";
@@ -98,8 +705,9 @@ export interface ChatSendMessage extends ChatRequest {
/**
* Server → client: one `AgentEvent` from an in-flight turn (text-delta,
- * tool-call, usage, done, turn-sealed, …). Fold these into the transcript
- * exactly as the HTTP NDJSON stream — same events, different carrier.
+ * tool-call, usage, done, turn-sealed, …). The client folds these into its
+ * transcript exactly as it folds the HTTP NDJSON stream — same events, different
+ * carrier.
*/
export interface ChatDeltaMessage {
readonly type: "chat.delta";
@@ -117,9 +725,411 @@ export interface ChatErrorMessage {
readonly message: string;
}
-/** Every client → server WS message: surface ops + chat ops. Discriminate on `type`. */
-export type WsClientMessage = SurfaceClientMessage | ChatSendMessage;
+/**
+ * Client → server: start WATCHING a conversation's live turn events WITHOUT
+ * sending a message. This is what makes a turn viewable independently of who
+ * started it — a second device (multi-client handoff) or a client that reloaded
+ * mid-turn subscribes to receive the in-flight turn.
+ *
+ * On subscribe the server replays the CURRENT in-flight turn's events so far as
+ * `chat.delta` messages (so a late-joiner sees the whole running turn from its
+ * `turn-start`), then streams subsequent live events. If no turn is in-flight,
+ * nothing is replayed (the client relies on `GET /conversations/:id` history).
+ * A client infers "generating" from a replayed `turn-start` with no matching
+ * `done`/`turn-sealed` yet. Idempotent per `(connection, conversationId)`.
+ *
+ * NOTE: `chat.send` auto-subscribes the sending connection, so a client only needs
+ * `chat.subscribe` for conversations it is viewing but did not send to.
+ */
+export interface ChatSubscribeMessage {
+ readonly type: "chat.subscribe";
+ readonly conversationId: string;
+}
+
+/**
+ * Client → server: stop watching a conversation's turn events on this connection.
+ * Does NOT stop or affect the turn itself (the turn runs to completion regardless
+ * of subscribers). The server also drops all of a connection's subscriptions when
+ * the socket closes — again WITHOUT aborting any in-flight turn.
+ */
+export interface ChatUnsubscribeMessage {
+ readonly type: "chat.unsubscribe";
+ readonly conversationId: string;
+}
+
+/**
+ * Client → server: enqueue a message onto a conversation's message queue while
+ * a turn is generating (steering). The WebSocket counterpart of the HTTP
+ * `POST /conversations/:id/queue` (`QueueRequest`). Fire-and-forget: success is
+ * confirmed by the message-queue SURFACE updating (the FE renders the queue
+ * from the surface, not from a reply here); a failure (malformed/empty text,
+ * unknown conversation) arrives as a `chat.error`. When no turn is active, the
+ * enqueue starts a new turn (the turn's events stream as `chat.delta`s), so a
+ * client reuses this op for both "queue while generating" and "send" (the
+ * latter being equivalent to `chat.send`).
+ */
+export interface ChatQueueMessage {
+ readonly type: "chat.queue";
+ readonly conversationId: string;
+ readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
+}
+
+/**
+ * Every client → server WS message: surface ops (`@dispatch/ui-contract`) + chat
+ * ops. A server discriminates on `type`.
+ */
+export type WsClientMessage =
+ | SurfaceClientMessage
+ | ChatSendMessage
+ | ChatSubscribeMessage
+ | ChatUnsubscribeMessage
+ | ChatQueueMessage;
+
+/**
+ * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
+ * ops. A client discriminates on `type`.
+ */
+export type WsServerMessage =
+ | SurfaceServerMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ConversationOpenMessage
+ | ConversationStatusChangedMessage
+ | ConversationCompactedMessage;
+
+// ─── Conversation list + metadata ────────────────────────────────────────────
+
+/**
+ * Broadcast to all connected WS clients when a conversation is "opened" (e.g.
+ * via the CLI `--open` flag). The frontend decides whether to open/focus a tab
+ * — the backend just signals. Additive to `WsServerMessage`.
+ */
+export interface ConversationOpenMessage {
+ readonly type: "conversation.open";
+ readonly conversationId: string;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
+}
+
+/**
+ * Broadcast to all connected WS clients when a conversation's lifecycle status
+ * changes (active/idle/closed). The frontend uses this to sync tab state across
+ * devices in real time.
+ */
+export interface ConversationStatusChangedMessage {
+ readonly type: "conversation.statusChanged";
+ readonly conversationId: string;
+ readonly status: ConversationStatus;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
+}
+
+/**
+ * Broadcast to all connected WS clients when a conversation's history has been
+ * compacted (summarized). The frontend should reload the conversation history
+ * via `GET /conversations/:id` to reflect the compacted state.
+ */
+export interface ConversationCompactedMessage {
+ readonly type: "conversation.compacted";
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+/**
+ * Response for `GET /conversations` — the list of all known conversations,
+ * sorted by `lastActivityAt` descending (most recent first). Each entry carries
+ * enough metadata for a conversation picker UI (id, title, timestamps).
+ * Optional `?q=` query param filters by id prefix (short-id resolution).
+ */
+export interface ConversationListResponse {
+ readonly conversations: readonly ConversationMeta[];
+}
+
+/**
+ * Response for `GET /conversations/:id/last` — blocks server-side until the
+ * in-flight turn settles (if one is active), then returns the last assistant
+ * text message. `content` is empty if the conversation has no assistant message.
+ * `turnId` is the turn that produced the message (absent if no turn ran).
+ */
+export interface LastMessageResponse {
+ readonly conversationId: string;
+ readonly content: string;
+ readonly turnId?: string;
+}
+
+/**
+ * Response for `POST /conversations/:id/open` — confirms the conversation.open
+ * signal was broadcast to connected WS clients.
+ */
+export interface OpenConversationResponse {
+ readonly conversationId: string;
+}
+
+/**
+ * Request body for `PUT /conversations/:id/title` — set a human-readable title.
+ */
+export interface SetTitleRequest {
+ readonly title: string;
+}
+
+/**
+ * Response for `GET/PUT /conversations/:id/title` — the current title.
+ */
+export interface TitleResponse {
+ readonly conversationId: string;
+ readonly title: string;
+}
+
+/**
+ * Response for `POST /conversations/:id/compact` — confirms the conversation
+ * history was compacted (old messages summarized, recent messages retained).
+ */
+export interface CompactResponse {
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+/**
+ * Response for `GET /conversations/:id/compact-percent` — the token count
+ * at which automatic compaction triggers (0 = manual only).
+ */
+export interface CompactPercentResponse {
+ readonly conversationId: string;
+ readonly threshold: number;
+}
+
+/**
+ * Request body for `PUT /conversations/:id/compact-percent`.
+ */
+export interface SetCompactPercentRequest {
+ readonly threshold: number;
+}
+
+// ─── Workspaces ───────────────────────────────────────────────────────────────
+
+/**
+ * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All
+ * fields are optional and only applied when the workspace is first created;
+ * an existing workspace is returned as-is.
+ */
+export interface EnsureWorkspaceRequest {
+ /** Display title. Default: the workspace id. Only used on create. */
+ readonly title?: string;
+ /** Default cwd. Default: null (inherit server default). Only used on create. */
+ readonly defaultCwd?: string | null;
+}
+
+/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */
+export interface WorkspaceResponse extends Workspace {}
+
+/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */
+export interface WorkspaceListResponse {
+ readonly workspaces: readonly WorkspaceEntry[];
+}
+
+/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */
+export interface SetWorkspaceTitleRequest {
+ readonly title: string;
+}
+
+/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */
+export interface SetWorkspaceDefaultCwdRequest {
+ readonly defaultCwd: string | null;
+}
+
+/**
+ * Response of `DELETE /workspaces/:id`. All conversations in the workspace
+ * are closed (status → "closed") and reassigned to "default", then the
+ * workspace entity is deleted. `"default"` is non-deletable (HTTP 409).
+ */
+export interface DeleteWorkspaceResponse {
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
+}
+
+// ─── Computers (SSH handoff #2) ─────────────────────────────────────────────
+
+/**
+ * Response of `GET /computers` — every remote computer discovered from the
+ * system's `~/.ssh/config`, sorted by `alias`. Parallel to
+ * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a
+ * usage count). There is no Computer CRUD — to add one, the user adds a `Host`
+ * block to `~/.ssh/config` and Dispatch discovers it on the next read.
+ */
+export interface ComputerListResponse {
+ readonly computers: readonly ComputerEntry[];
+}
+
+/**
+ * Response of `GET /computers/:alias` — a single computer. Parallel to
+ * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users
+ * select; the remaining fields are resolved from the SSH config.
+ */
+export interface ComputerResponse extends Computer {}
+
+/**
+ * Response of `GET /computers/:alias/status` — the live connection state of a
+ * computer (whether Dispatch currently holds an open SSH session to it). Drives
+ * the frontend connection indicator. `error` is present only when
+ * `state === "error"`; `knownHost` mirrors the read-only `Computer` field.
+ */
+export interface ComputerStatusResponse {
+ readonly alias: string;
+ readonly state: "disconnected" | "connecting" | "connected" | "error";
+ readonly error?: string;
+ readonly knownHost: boolean;
+}
+
+/**
+ * Body of `PUT /conversations/:id/computer` — set or clear the conversation's
+ * persisted computer selection (the computer analog of `SetCwdRequest`). Pass
+ * `null` to clear → the conversation inherits the workspace's
+ * `defaultComputerId`, then `null`/local. An unknown alias is not validated here
+ * (the connection resolves at turn time; an unreachable host → turn error, not
+ * a 400). Mirrors the cwd/model PUT clear semantics.
+ */
+export interface SetConversationComputerRequest {
+ readonly computerId: string | null;
+}
+
+/**
+ * Response of `GET /conversations/:id/computer`. `computerId` is the persisted
+ * SSH `Host` alias, or `null` when never set (the conversation then inherits
+ * the workspace default → local). Parallel to `CwdResponse`.
+ */
+export interface ConversationComputerResponse {
+ readonly conversationId: string;
+ readonly computerId: string | null;
+}
-/** Every server → client WS message: surface ops + chat ops. Discriminate on `type`. */
-export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage;
+/**
+ * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's
+ * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`).
+ * `null` means local (no SSH). Conversations in the workspace with no
+ * `computerId` of their own inherit this.
+ */
+export interface SetWorkspaceDefaultComputerRequest {
+ readonly computerId: string | null;
+}
+
+/**
+ * Response of `POST /computers/:alias/test` — the result of a one-shot
+ * connectivity probe (Dispatch opens an SSH connection to the alias, runs a
+ * trivial command, then closes). `ok` is true on success; `error` carries the
+ * failure reason (e.g. auth refused, host unreachable) when `ok` is false.
+ */
+export interface TestComputerResponse {
+ readonly alias: string;
+ readonly ok: boolean;
+ readonly error?: string;
+}
+
+// ── Provider concurrency limits ([email protected]) ───────────────────
+
+/**
+ * Response of `GET /concurrency/limits` — all providers with configured
+ * concurrency limits. Each entry pairs a provider id (e.g. "umans",
+ * "openai-compat") with its maximum concurrent in-flight requests. Providers
+ * not listed here have no limit (unlimited).
+ */
+export interface ConcurrencyLimitsResponse {
+ readonly limits: readonly {
+ readonly providerId: string;
+ readonly limit: number;
+ }[];
+}
+/**
+ * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency
+ * limit for a provider. `limit` must be a positive integer. When a limit is
+ * set, requests beyond the limit queue (oldest-agent-first) rather than being
+ * sent immediately.
+ */
+export interface SetConcurrencyLimitRequest {
+ readonly limit: number;
+}
+/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */
+export interface ConcurrencyLimitResponse {
+ readonly providerId: string;
+ readonly limit: number;
+}
+/**
+ * One provider's live concurrency status.
+ *
+ * - `inFlight`: how many slots are currently held (tokens being generated).
+ * - `queued`: how many agents are waiting for a slot.
+ * - `paused`: whether the queue is paused due to a 429 backoff.
+ * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused.
+ * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held
+ * this long before the next waiter is admitted — covers the upstream
+ * provider's accounting lag. Configurable + persisted per provider.
+ * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429
+ * (adaptive headroom, one-way, persisted). The user restores the limit
+ * manually via `PUT /concurrency/limits/:providerId`, which clears the flag.
+ * When `true`, the frontend renders a visible notice/banner.
+ * - `autoReducedFrom`: the original limit before auto-reduction (present only
+ * when `autoReduced` is true).
+ * - `notice`: a human-readable notice string for the frontend to render as a
+ * banner when the limit was auto-reduced (present only when `autoReduced`).
+ */
+export interface ConcurrencyStatusEntry {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ readonly pausedUntil?: number;
+ readonly cooldownMs: number;
+ readonly autoReduced: boolean;
+ readonly autoReducedFrom?: number;
+ readonly notice?: string;
+}
+/**
+ * Response of `GET /concurrency/status` — live status for every provider with a
+ * configured limit. Providers without a limit are absent (they are unlimited).
+ */
+export interface ConcurrencyStatusResponse {
+ readonly providers: readonly ConcurrencyStatusEntry[];
+}
+
+// ─── Provider concurrency cooldown ────────────────────────────────────────────
+
+/**
+ * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release
+ * cooldown (ms) for a provider. A recycled slot is held this long before the
+ * next waiter is admitted, covering the upstream provider's accounting lag.
+ * When no cooldown was explicitly set, the server default (350ms) is returned.
+ */
+export interface ConcurrencyCooldownResponse {
+ readonly providerId: string;
+ readonly cooldownMs: number;
+}
+
+/**
+ * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown
+ * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no
+ * cooldown, instant re-admission). The value is persisted and applied to
+ * subsequently recycled slots.
+ */
+export interface SetConcurrencyCooldownRequest {
+ readonly cooldownMs: number;
+}
```
+
diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md
index 3962fc1..d751af8 100644
--- a/.dispatch/ui-contract.reference.md
+++ b/.dispatch/ui-contract.reference.md
@@ -5,22 +5,49 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/ui-contract` normally — this
> file is for READING only.
>
-> **Orchestrator:** this is a SNAPSHOT — regenerate it whenever `ui-contract` changes.
+> **Orchestrator:** this is a SNAPSHOT of `[email protected]` — regenerate it whenever
+> `ui-contract` changes.
+>
+> **2026-06-12 delta (CR-2/CR-4 handoff — package bumped `0.1.0` → `0.2.0`):** adds the optional
+> `scope?: "global" | "conversation"` to `SurfaceCatalogEntry` so a client can skip re-subscribing
+> GLOBAL surfaces on a conversation switch. ABSENT means assume conversation-scoped (the
+> conservative always-send-conversationId policy remains correct for both). Emitted today:
+> `loaded-extensions` → `"global"`, `cache-warming` → `"conversation"`. Also (CR-4d, no shape
+> change): the initial `surface` reply to a conversation-scoped subscribe ECHOES `conversationId`
+> as documented (was already on backend HEAD; verify with a freshly-booted backend).
+>
+> **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.
```ts
/**
* UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
*
* A SURFACE is a "data transportation surface": a typed description of what data an
- * extension exposes, its semantics, and the actions that can act on it — NOT UI.
- * Any client renders a surface in its own idiom (web/Svelte, CLI, future TUI/mobile).
- * Types-only, zero runtime, zero `@dispatch/*` deps.
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI. It
+ * carries STRUCTURE + SEMANTICS + ACTIONS, never styling and never a rendering-
+ * framework token. Any client (web/Svelte, CLI, future TUI/mobile) renders a surface
+ * in its own idiom, so swapping or adding a client is a zero-backend-change event.
+ *
+ * This package is types-only (zero runtime) and has ZERO `@dispatch/*` dependencies,
+ * so a separate client repo can depend on JUST this contract.
*/
-/** Where a surface mounts — a coarse, semantic placement hint, NOT layout/CSS. Open string. */
+/**
+ * Where a surface mounts — a coarse, semantic placement hint, NOT a layout/CSS
+ * instruction. A client maps a region to its own idiom; an unknown region falls back
+ * to the client's default placement. Deliberately left open (a `string`).
+ */
export type Region = string;
-/** A typed reference to a backend action a field can invoke (client posts payload back). */
+/**
+ * A typed reference to a backend action a field can invoke. The client posts it back
+ * (with a payload); the surface id comes from context.
+ */
export interface ActionRef {
readonly actionId: string;
}
@@ -31,12 +58,16 @@ export interface SurfaceOption {
readonly label: string;
}
-/** A field within a surface — a SEMANTIC value, not a widget. `kind` is the discriminant. */
+/**
+ * A field within a surface — a SEMANTIC value, not a widget. `kind` is the
+ * discriminant a client switches on to pick a renderer.
+ */
export type SurfaceField =
| ToggleField
| ProgressField
| SelectorField
| StatField
+ | NumberField
| ButtonField
| CustomField;
@@ -48,7 +79,7 @@ export interface ToggleField {
readonly action: ActionRef;
}
-/** A bounded ratio in [0, 1] with a label. Read-only. */
+/** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */
export interface ProgressField {
readonly kind: "progress";
readonly label: string;
@@ -71,6 +102,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", "min"). 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";
@@ -79,8 +128,10 @@ export interface ButtonField {
}
/**
- * The escape hatch: data that fits no semantic field kind. Opaque `payload` + a
- * `rendererId`; clients WITH a renderer for that id show it, others GRACEFULLY SKIP.
+ * The escape hatch: data that fits no semantic field kind. Carries an opaque
+ * `payload` + a `rendererId`; clients WITH a renderer for that id show it, others
+ * GRACEFULLY SKIP. Keep rare — and the owning extension should export a typed
+ * payload type so its bespoke renderer narrows `payload` via a typed symbol.
*/
export interface CustomField {
readonly kind: "custom";
@@ -88,7 +139,10 @@ export interface CustomField {
readonly payload: unknown;
}
-/** A surface: an ordered set of fields mounted in a region, with a title. */
+/**
+ * A surface: an ordered set of fields mounted in a region, with a title. The atomic
+ * unit a backend extension contributes and a client renders.
+ */
export interface SurfaceSpec {
readonly id: string;
readonly region: Region;
@@ -96,43 +150,77 @@ export interface SurfaceSpec {
readonly fields: readonly SurfaceField[];
}
-/** A surface-catalog entry — discovery metadata only (no field data). */
+/**
+ * A surface-catalog entry — discovery metadata only (no field data).
+ */
export interface SurfaceCatalogEntry {
readonly id: string;
readonly region: Region;
readonly title: string;
+ /**
+ * Whether the surface's spec/values differ per conversation ("conversation")
+ * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces
+ * on a conversation switch (they ignore `conversationId`). Optional + additive:
+ * when absent, a client should assume conversation-scoped (the conservative
+ * "always send the focused conversationId" policy still works for both).
+ */
+ readonly scope?: "global" | "conversation";
}
/** 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 (pushed over the WS channel). v1 carries
+ * the full new spec (the simplest "patch").
+ *
+ * `conversationId` is present only for a CONVERSATION-SCOPED surface (one whose
+ * spec/values differ per conversation, e.g. cache-warming controls): it tells the
+ * client which conversation this update pertains to. A global surface omits it.
+ */
export interface SurfaceUpdate {
readonly surfaceId: string;
readonly spec: SurfaceSpec;
+ readonly conversationId?: string;
}
-// ── Surface WebSocket protocol (slice 1: surfaces only) ──────────────────────
+// ── Surface WebSocket protocol ────────────────────────────────────────────────
/** A client → server message on the surface channel. */
export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
+/**
+ * Begin receiving live updates for a surface (server replies with `surface`, then `update`s).
+ *
+ * For a CONVERSATION-SCOPED surface, include the `conversationId` whose state you
+ * want — the server resolves the spec for that conversation and pushes its updates.
+ * Omit it for a global surface (or to view a conversation-scoped surface with no
+ * conversation in focus → the surface decides its default/empty state).
+ */
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 +236,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. */
@@ -160,7 +253,7 @@ export interface SurfaceUpdateMessage {
readonly update: SurfaceUpdate;
}
-/** A surface-scoped error. */
+/** A surface-scoped error (e.g. unknown surface id, invoke failed). */
export interface SurfaceErrorMessage {
readonly type: "error";
readonly surfaceId?: string;
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index ccf07bd..b430a45 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,7 +4,59 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes.
+>
+> **2026-06-27 delta (workspace starring — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a
+> required `starred: boolean` (defaults to `false` on creation). A starred workspace's agents receive
+> PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces
+> (oldest-agent-first within each group). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints
+> (no body; both create-on-miss and return the updated `Workspace`). `PUT /workspaces/:id` does NOT accept a
+> `starred` field. See `backend-handoff.md`.
+>
+> **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus`
+> widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a
+> per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows
+> a loading ring (vs the dots of `active`). See `backend-handoff.md` CR-13.
+>
+> **2026-06-26 delta (vision handoff — ADDITIVE to `[email protected]`, NO version bump):** adds a new
+> `ImageChunk` variant to the `Chunk` union (`{ type: "image", url, mimeType? }` — `url` is a base64 data
+> URL or an `http(s)://` URL) and a transport-facing `ImageInput` (`{ url, mimeType? }`, what a client
+> sends on `ChatRequest.images`; the orchestrator converts each into an `ImageChunk` on the persisted user
+> message). Vision-capable models receive image chunks natively; non-vision models never see them directly
+> — the orchestrator's vision handoff transcribes each to a text description (persisted as a separate
+> `text` chunk in the SAME user message). See `backend-handoff.md` §2j.
+>
+> **2026-06-26 update (image storage — NO type change, behavior only):** `ImageChunk.url` for PERSISTED
+> chunks is now a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by the backend's
+> new `GET /images/:conversationId/:imageId` endpoint (raw bytes + correct Content-Type), NOT a base64 data
+> URL — images are stored on disk under tmp, not in the SQLite conversation store (keeps payloads small).
+> `ImageInput.url` (what a client SENDS on `ChatRequest.images`) is UNCHANGED — still a data URL or
+> `http(s)://` URL; the backend saves it to tmp and returns the compact path in the persisted chunk. A client
+> resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or an
+> absolute URL passes through unchanged. See `backend-handoff.md` §2j.
+>
+> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds
+> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required
+> `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A
+> workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that
+> haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`.
+>
+> **2026-06-25 delta (SSH handoff #1 — ADDITIVE to `[email protected]`, NO version bump):** adds a REQUIRED
+> `defaultComputerId: string | null` on `Workspace` (null = local / no SSH; the computer analog of
+> `defaultCwd`) and two new read-only view types: `Computer` (a discovered `~/.ssh/config` `Host` alias)
+> and `ComputerEntry extends Computer` (a list entry with a `usageCount`). `alias` IS the `computerId`
+> users select (persisted per-conversation/per-workspace like cwd). The full HTTP API surface
+> (`GET /computers`, `PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`,
+> `GET /computers/:alias/status`, `chat.send computerId`) comes in a LATER handoff — NOT consumed yet.
+>
+> **⚠️ CROSS-REPO DIVERGENCE (2026-06-25, BLOCKING FE typecheck):** the backend `feature/ssh-support`
+> branch (where the SSH types landed) was cut from `8a74335` and is MISSING the `TurnProviderRetryEvent` /
+> `provider-retry` `AgentEvent` addition that is on `dev` (and which the FE already consumes — see §2c of
+> `backend-handoff.md`). The mirror below KEEPS `TurnProviderRetryEvent` (it is the FE's expected contract
+> and matches `dev`); it is marked where it appears. Until the backend merges `dev` into
+> `feature/ssh-support`, the FE pinned to the `feature/ssh-support` wire will NOT typecheck (11 errors,
+> all the missing `provider-retry` seam). The SSH `Computer`/`defaultComputerId` types ARE present on
+> `feature/ssh-support` and are consumed below.
```ts
/**
@@ -23,7 +75,16 @@ export type Role = "system" | "user" | "assistant" | "tool";
/** Opaque identifier for a turn (one user→assistant cycle). */
export type TurnId = string & { readonly __brand: "TurnId" };
-/** Opaque identifier for a step (one LLM round-trip within a turn). */
+/**
+ * Opaque identifier for a step (one LLM round-trip within a turn). It is the
+ * authoritative grouping key for the tool calls a model batches together in a
+ * single step (parallel/batched calls): every `tool-call`/`tool-result` event
+ * and every persisted tool chunk (`ToolCallChunk`/`ToolResultChunk`) from the
+ * same step carries the SAME `stepId`, so a client groups a batch purely by
+ * equality — identically on the live stream and in replayed history. Per-turn
+ * unique and gap-free in step order; treat it as opaque (do not parse it). The
+ * runtime derives it deterministically from the turn id + 0-based step index.
+ */
export type StepId = string & { readonly __brand: "StepId" };
/**
@@ -36,7 +97,8 @@ export type Chunk =
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
- | SystemChunk;
+ | SystemChunk
+ | ImageChunk;
/** A piece of plain text content from the assistant or user. */
export interface TextChunk {
@@ -60,6 +122,18 @@ export interface ToolCallChunk {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
+ /**
+ * The step that produced this call — generation provenance stamped by the
+ * runtime when the model emits the call (NOT storage metadata like `seq`,
+ * which is why it lives on the chunk and travels with it through persistence
+ * and replay). Tool calls a model batches together in one step share the same
+ * `stepId`: the grouping key for rendering a parallel batch as one unit, and
+ * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional:
+ * absent on chunks reconstructed outside a turn and on rows persisted before
+ * this field existed, so a consumer must tolerate its absence (render
+ * ungrouped).
+ */
+ readonly stepId?: StepId;
}
/**
@@ -73,6 +147,15 @@ export interface ToolResultChunk {
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
+ /**
+ * The step that produced the originating call — equal to the `stepId` on the
+ * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result`
+ * AgentEvent, so a consumer groups a step's calls with their results.
+ * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`).
+ * Optional for the same reasons; `reconcile` copies it from the originating
+ * call onto a synthesized (interrupted) result.
+ */
+ readonly stepId?: StepId;
}
/** An error that occurred during generation or tool dispatch. */
@@ -92,6 +175,51 @@ export interface SystemChunk {
}
/**
+ * An image attached to a message (e.g. a user-pasted screenshot or pasted
+ * photo). Carries a `url` that is EITHER a base64 data URL
+ * (`data:image/png;base64,…`) OR an `http(s)://` URL OR — for PERSISTED chunks
+ * (history/replay) — a compact relative HTTP path (`/images/<conversationId>/
+ * <uuid>.png`) served by the backend's `GET /images/:conversationId/:imageId`
+ * endpoint (images are stored on disk under tmp, NOT in the conversation store,
+ * to keep SQLite payloads small). A client resolves a relative path against its
+ * API base URL; a data URL (the optimistic echo / a pasted image) or an
+ * absolute URL is rendered as-is. Vision-capable models receive it natively
+ * (the provider serializes it to its image-content format); non-vision models
+ * never see it directly — the orchestrator's **vision handoff** transcribes it
+ * to a text description (via a vision-capable model) and feeds that text
+ * instead, so a text-only model can still reason about the image's contents.
+ *
+ * When a transcription was performed, it is persisted as a separate `text`
+ * chunk alongside the `image` chunk in the SAME user message, so the
+ * description is reused on every later turn (no re-transcription) and a
+ * client renders both the original image and its textual analysis.
+ */
+export interface ImageChunk {
+ readonly type: "image";
+ /** Image source: a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or a compact relative path (`/images/<conv>/<uuid>.png`) for persisted chunks. */
+ readonly url: string;
+ /**
+ * Optional MIME type of the image (e.g. `"image/png"`). Inferred from the
+ * data URL when absent; present so a client can render an icon/label without
+ * parsing the URL. Optional — callers that only have a URL omit it.
+ */
+ readonly mimeType?: string;
+}
+
+/**
+ * An image a client attaches to a chat message (`ChatRequest.images`). The
+ * transport-facing input shape; the orchestrator converts each `ImageInput`
+ * into an `ImageChunk` on the persisted user message. Carries the same `url`
+ * semantics as `ImageChunk.url`.
+ */
+export interface ImageInput {
+ /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */
+ readonly url: string;
+ /** Optional MIME type (e.g. `"image/png"`). Optional — inferred from the data URL when absent. */
+ readonly mimeType?: string;
+}
+
+/**
* A chat message: a role plus an ordered sequence of chunks. Messages are the
* unit passed to and from the provider; chunks are the unit persisted and
* rendered.
@@ -103,13 +231,21 @@ export interface ChatMessage {
/**
* A persisted chunk plus its sync metadata. The append-only conversation log
- * stamps every chunk with a monotonic, gap-free, per-conversation `seq` (the
- * sync cursor, assigned in append order) and records the `role` of the message
- * it belongs to. This makes a flat seq-ordered stream both incrementally
- * syncable ("give me chunks after seq N") and regroupable into messages by the
- * client. `chunk` is the pure content unit, unchanged — `Chunk` itself never
- * carries storage metadata (it is also passed to/from the provider, which has
- * no use for a cursor).
+ * stamps every chunk with a **1-based**, monotonic, gap-free, per-conversation
+ * `seq` (the sync cursor, assigned in append order) and records the `role` of
+ * the message it belongs to. This makes a flat seq-ordered stream both
+ * incrementally syncable ("give me chunks after seq N") and regroupable into
+ * messages by the client.
+ *
+ * The 1-based start is a CONTRACTUAL GUARANTEE (not an implementation detail):
+ * a conversation's first chunk is always `seq === 1` and numbering never skips,
+ * so a client holding only a windowed suffix of the log can derive "older
+ * chunks exist server-side" purely from `oldestLoaded.seq > 1` — no separate
+ * has-older flag is needed (or provided). `chunk` is the content unit — `Chunk` carries no storage/sync cursor
+ * (`seq` lives here on the envelope, not on the chunk, since it is assigned by
+ * the store and the provider has no use for it). A chunk MAY still carry
+ * generation provenance assigned at production time (e.g. a tool chunk's
+ * `stepId`), which is intrinsic to the content and so travels with it.
*/
export interface StoredChunk {
readonly seq: number;
@@ -117,6 +253,20 @@ export interface StoredChunk {
readonly chunk: Chunk;
}
+// ─── Reasoning effort ───────────────────────────────────────────────────────
+
+/**
+ * The per-request thinking-depth knob: how much extended thinking / reasoning
+ * the model should spend before answering. Provider-agnostic ladder; each
+ * provider maps a level to its native knob in its own code (e.g. an Anthropic
+ * provider maps it to a `thinking.budget_tokens` value) and MAY ignore levels
+ * (or the field entirely) that its backend cannot express.
+ *
+ * Resolution (owned by the session-orchestrator): per-turn request value →
+ * persisted per-conversation value → default `"high"`.
+ */
+export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
+
// ─── Usage ──────────────────────────────────────────────────────────────────
/**
@@ -130,6 +280,88 @@ export interface Usage {
readonly cacheWriteTokens?: number;
}
+// ─── Persisted metrics ───────────────────────────────────────────────────────
+
+/**
+ * Durable per-step metrics for a completed step — the persisted, replayable
+ * counterpart of the live `usage` + `step-complete` events. Combines the step's
+ * token usage with its generation timing so a client reopening a past
+ * conversation renders the same per-step token/latency breakdown it would have
+ * seen live. Built from the turn's events, stored by `conversation-store`, and
+ * served by `GET /conversations/:id/metrics`.
+ */
+export interface StepMetrics {
+ readonly stepId: StepId;
+ /** The step's token usage (all four counters; cache fields optional per `Usage`). */
+ readonly usage: Usage;
+ /** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */
+ readonly ttftMs?: number;
+ /** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */
+ readonly decodeMs?: number;
+ /** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */
+ readonly genTotalMs?: number;
+}
+
+/**
+ * Durable per-turn metrics for a completed (sealed) turn — the persisted,
+ * replayable counterpart of the live `done` event's aggregate `usage` +
+ * `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all
+ * steps; `steps` carries each step's `StepMetrics` in step order. Persisted per
+ * turn by `conversation-store` (returned in turn-append order) and served by
+ * `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried
+ * on every `AgentEvent`, the join key to the live stream.)
+ */
+export interface TurnMetrics {
+ readonly turnId: string;
+ /** Aggregate token usage across all steps in the turn. */
+ readonly usage: Usage;
+ /** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */
+ readonly durationMs?: number;
+ /** Per-step metrics in step order. */
+ readonly steps: readonly StepMetrics[];
+ /**
+ * **Context size** — tokens the conversation occupies as of this turn: the
+ * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`),
+ * NOT the aggregate `usage` (which sums per-step prompts and overcounts a
+ * multi-step turn). The persisted, replayable counterpart of
+ * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client
+ * reopening a past conversation reads the LAST turn's `contextSize` as the
+ * current context usage. Optional: absent when no per-step usage was available.
+ */
+ readonly contextSize?: number;
+}
+
+// ─── Message queue + steering ───────────────────────────────────────────────
+
+/**
+ * A user message held in a conversation's message queue, awaiting mid-turn
+ * steering delivery. The message-queue extension owns the queue and exposes it
+ * as a per-conversation `custom` surface field; this type is the shared shape
+ * the surface payload, the enqueue response, and the extension's service all
+ * use (so a separate frontend repo can depend on the wire alone to render it).
+ */
+export interface QueuedMessage {
+ /** Stable id (client-visible) for UI keying + dedup. */
+ readonly id: string;
+ /** The message text the client enqueued. */
+ readonly text: string;
+ /** When the message was enqueued (epoch-ms). */
+ readonly queuedAt: number;
+}
+
+/**
+ * The payload of the message-queue extension's per-conversation `custom`
+ * surface field (`rendererId: "message-queue"`): the current queue snapshot a
+ * frontend renders. Carried on the SURFACE channel (NOT the chat stream) — the
+ * queue is control/state, distinct from turn content. An empty `messages`
+ * array means the queue is empty (no pending steering). The frontend moves a
+ * message from this queue surface into the transcript when it is drained (the
+ * surface clears) and/or when the matching `TurnSteeringEvent` arrives.
+ */
+export interface QueuePayload {
+ readonly messages: readonly QueuedMessage[];
+}
+
// ─── Outward events ─────────────────────────────────────────────────────────
/**
@@ -139,15 +371,19 @@ export interface Usage {
export type AgentEvent =
| StatusEvent
| TurnStartEvent
+ | TurnInputEvent
| TurnTextDeltaEvent
| TurnReasoningDeltaEvent
| TurnToolCallEvent
| TurnToolResultEvent
| TurnToolOutputEvent
| TurnUsageEvent
+ | TurnStepCompleteEvent
| TurnErrorEvent
+ | TurnProviderRetryEvent // ⚠️ divergent: present on `dev`, MISSING on the pinned `feature/ssh-support` wire (see header + backend-handoff.md §2c)
| TurnDoneEvent
- | TurnSealedEvent;
+ | TurnSealedEvent
+ | TurnSteeringEvent;
/** Status change for a conversation (e.g. idle → running). */
export interface StatusEvent {
@@ -163,6 +399,25 @@ export interface TurnStartEvent {
readonly turnId: string;
}
+/**
+ * The user prompt that opened this turn, surfaced INTO the turn's outward event
+ * stream. The user message is persisted only when the turn seals (atomically with
+ * the assistant reply), so without this event a client that is merely WATCHING a
+ * conversation (subscribed but not the sender) has no source for the prompt text
+ * mid-turn — it would see the streaming reply with no preceding user bubble until
+ * seal. Emitted once, as the FIRST event of the turn (before `turn-start`), so it
+ * is buffered and replayed to every subscriber — live and late-join — exactly like
+ * the rest of the turn. The sender already echoes its own prompt optimistically, so
+ * a consumer should de-dup against that (e.g. by text); a pure watcher renders it
+ * directly. Carries the raw prompt `text` (the same text passed to the provider).
+ */
+export interface TurnInputEvent {
+ readonly type: "user-message";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly text: string;
+}
+
/** Incremental text content from the model during a turn. */
export interface TurnTextDeltaEvent {
readonly type: "text-delta";
@@ -184,6 +439,14 @@ export interface TurnToolCallEvent {
readonly type: "tool-call";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step that produced this call. Tool calls a model batches together in
+ * one step share the same `stepId` — the grouping key for rendering a
+ * parallel batch as one unit. Matches the `stepId` on the matching
+ * `tool-result` event and on the persisted tool chunk
+ * (`StoredChunk.chunk.stepId`).
+ */
+ readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
@@ -194,10 +457,23 @@ export interface TurnToolResultEvent {
readonly type: "tool-result";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step that produced the originating call. Equal to the `stepId` on the
+ * matching `tool-call` event (same `toolCallId`) and on the persisted tool
+ * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with
+ * their results.
+ */
+ readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
+ /**
+ * How long the tool took to execute (dispatch → result), in milliseconds —
+ * the backend's authoritative execution time, distinct from any client-side
+ * wall-clock. Optional: present only when the runtime was given a clock.
+ */
+ readonly durationMs?: number;
}
/** Streaming output from a tool execution (e.g. shell stdout/stderr). */
@@ -215,9 +491,43 @@ export interface TurnUsageEvent {
readonly type: "usage";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step this usage report belongs to, so a consumer can attribute tokens
+ * per step (and join with the matching `step-complete` timing by `stepId`).
+ * Optional: absent when the runtime had no step context, and on usage emitted
+ * before this field existed.
+ */
+ readonly stepId?: StepId;
readonly usage: Usage;
}
+/**
+ * A step (one LLM round-trip) has completed — the authoritative per-step metrics
+ * packet, emitted once at the step's end (after the generation stream finishes),
+ * so its timing is final (unlike `usage`, which may arrive mid-stream). Carries
+ * the step's generation timing; join to the step's tokens via `stepId` on the
+ * `usage` event. All timing fields are optional: present only when the runtime
+ * was given a clock, and `ttftMs`/`decodeMs` additionally require that a first
+ * content token (text or reasoning) was observed this step.
+ */
+export interface TurnStepCompleteEvent {
+ readonly type: "step-complete";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly stepId: StepId;
+ /** Time to first token: stream start → first text/reasoning delta. */
+ readonly ttftMs?: number;
+ /** Decode time: first token → stream end (generation total − TTFT). */
+ readonly decodeMs?: number;
+ /**
+ * Total generation time for the step: stream start → stream end. Present
+ * whenever a clock was available, even if no first token was seen (in which
+ * case `ttftMs`/`decodeMs` are absent). When a first token was seen,
+ * `genTotalMs === ttftMs + decodeMs`.
+ */
+ readonly genTotalMs?: number;
+}
+
/** An error occurred during the turn. */
export interface TurnErrorEvent {
readonly type: "error";
@@ -227,12 +537,63 @@ export interface TurnErrorEvent {
readonly code?: string;
}
+/**
+ * A retryable provider error is being retried with backoff. Emitted once per
+ * scheduled retry, BEFORE the sleep, so the UI can show "⚠ Server overloaded —
+ * retrying in 5s…" immediately. TRANSIENT: emitted to the frontend but NOT
+ * persisted into the model's message history (it never pollutes the prompt).
+ *
+ * When the retry budget is exhausted, the existing `error` event is emitted and
+ * the turn seals — so the final failure is still a persisted error. `attempt` is
+ * 0-based (the Nth retry about to happen); `delayMs` is the scheduled sleep
+ * before that retry fires.
+ */
+export interface TurnProviderRetryEvent {
+ readonly type: "provider-retry";
+ readonly conversationId: string;
+ readonly turnId: string;
+ /** 0-based: this is the Nth retry about to happen. */
+ readonly attempt: number;
+ /** ms the client should expect to wait before the retry fires. */
+ readonly delayMs: number;
+ /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */
+ readonly message: string;
+ /** The HTTP code when known (e.g. "429"). */
+ readonly code?: string;
+}
+
/** The turn has completed (model finished generating). */
export interface TurnDoneEvent {
readonly type: "done";
readonly conversationId: string;
readonly turnId: string;
readonly reason: string;
+ /**
+ * Total wall-clock duration of the turn (turn start → turn end), in
+ * milliseconds. Optional: present only when the runtime was given a clock.
+ */
+ readonly durationMs?: number;
+ /**
+ * Aggregate token usage across all steps in the turn — a convenience total so
+ * a consumer need not sum the per-step `usage` events. Optional (absent if the
+ * provider reported no usage).
+ */
+ readonly usage?: Usage;
+ /**
+ * **Context size** — the number of tokens the conversation now occupies: this
+ * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full
+ * prompt sent into the last LLM round-trip plus that round-trip's output). This
+ * is the "tokens in context" figure a client renders as the chat's current
+ * context usage, and a client treats the LATEST turn's value as the live total.
+ *
+ * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's
+ * `inputTokens`, which overcounts a multi-step / tool-calling turn because every
+ * step re-prefills the growing prompt — the final step's input already includes
+ * all prior context, so its input+output is the true occupancy. Optional: absent
+ * when no per-step usage was observed this turn (mirrors `usage`). A later field
+ * will carry the model's max context-window LIMIT; this is only the current size.
+ */
+ readonly contextSize?: number;
}
/**
@@ -244,4 +605,176 @@ export interface TurnSealedEvent {
readonly conversationId: string;
readonly turnId: string;
}
+
+/**
+ * A steering message was injected into an in-flight turn at the tool-result
+ * boundary (the model sees it alongside the tool results and may adjust
+ * course). Drawn from the conversation's message queue (which the drain
+ * clears); the cleared queue arrives as a message-queue SURFACE update, while
+ * THIS event carries the injected `text` so a frontend can place a user bubble
+ * in the transcript live — and so a late-joining watcher sees it before seal
+ * (mirroring `TurnInputEvent` for the opening prompt; emitted into the
+ * in-flight buffer by the session-orchestrator).
+ *
+ * Emitted by the session-orchestrator (in its `drainSteering` wrapper) only
+ * when the kernel drained a non-empty queue at a tool-result boundary. If the
+ * turn instead ENDS with a non-empty queue (no tool call fired), the queue is
+ * carried into a NEW turn whose opening `user-message` event covers the
+ * transcript — so no `steering` event is emitted in that case. One `steering`
+ * event per drain; the combined text of all drained messages.
+ */
+export interface TurnSteeringEvent {
+ readonly type: "steering";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly text: string;
+}
+
+// ─── Conversation metadata ───────────────────────────────────────────────────
+
+/**
+ * The lifecycle status of a conversation, used for tab persistence across
+ * devices. `active` = an agent is currently generating; `queued` = the turn is
+ * in flight but waiting for a per-provider concurrency slot (broadcast-only,
+ * never persisted — CR-13; the tab shows a ring vs the dots of `active`);
+ * `idle` = exists but not generating; `closed` = user dismissed the tab
+ * (hidden from the tab bar, not deleted). New conversations start as `idle`;
+ * transitions to `active` on turn-start (or `queued` when the request blocks on
+ * a concurrency slot before generation begins), back to `idle` on turn
+ * done/error, and to `closed` on user close.
+ */
+export type ConversationStatus = "active" | "queued" | "idle" | "closed";
+
+/**
+ * Metadata for a conversation, returned by `GET /conversations` (the list
+ * endpoint). The title defaults to the first user message (truncated) and can
+ * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write;
+ * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle
+ * for cross-device persistence.
+ */
+export interface ConversationMeta {
+ readonly id: string;
+ readonly createdAt: number;
+ readonly lastActivityAt: number;
+ readonly title: string;
+ readonly status: ConversationStatus;
+ /**
+ * The workspace this conversation belongs to. Always present; reads as
+ * `"default"` for legacy conversations that were never explicitly assigned.
+ * Conversations created with no `workspaceId` default to `"default"`.
+ */
+ readonly workspaceId: string;
+ /**
+ * Set on a compacted conversation: points to the archive conversation ID
+ * that holds the full pre-compaction history. Absent on conversations
+ * that have never been compacted.
+ */
+ readonly compactedFrom?: string;
+}
+
+// ─── Compaction ──────────────────────────────────────────────────────────────
+
+/**
+ * Result of a compaction operation. `summary` is the text the model produced;
+ * `messagesKept` is how many recent messages were retained after the summary;
+ * `messagesSummarized` is how many old messages were replaced by the summary.
+ * `newConversationId` is the ID of the new conversation that holds the full
+ * pre-compaction history (non-destructive — the original history is preserved).
+ */
+export interface CompactionResult {
+ readonly summary: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+// ─── Workspaces ──────────────────────────────────────────────────────────────
+
+/**
+ * A named, URL-driven grouping of conversations that owns a default cwd.
+ * Every conversation belongs to exactly one workspace; conversations that
+ * haven't set their own per-conversation cwd inherit `defaultCwd`.
+ *
+ * Workspaces are backend-owned (so cross-device just works): the workspace
+ * entity and each conversation's `workspaceId` live server-side. The
+ * `"default"` workspace is always present and non-deletable; conversations
+ * created with no `workspaceId` are assigned to `"default"`.
+ */
+export interface Workspace {
+ /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */
+ readonly id: string;
+ /** Display title (editable). Defaults to `id` on creation. */
+ readonly title: string;
+ /** The workspace's default cwd, or `null` (fall through to server default). */
+ readonly defaultCwd: string | null;
+ /**
+ * The workspace's default computer — an SSH config `Host` alias that
+ * conversations in this workspace inherit when they set no `computerId` of
+ * their own. `null` means local (no SSH; today's behavior). The computer
+ * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer`
+ * (per-conv `computerId` → this → `null`/local).
+ */
+ readonly defaultComputerId: string | null;
+ /**
+ * Whether the workspace is starred by the user. Starred workspaces receive
+ * PRIORITY in the concurrency limiter queue — their agents jump ahead
+ * of agents from non-starred workspaces (oldest-agent-first within each group).
+ * Defaults to `false` on creation.
+ */
+ readonly starred: boolean;
+ /** Epoch-ms when the workspace was first created. */
+ readonly createdAt: number;
+ /** Epoch-ms of the most recent conversation activity in this workspace. */
+ readonly lastActivityAt: number;
+}
+
+/**
+ * A workspace entry in the list response (`GET /workspaces`) — a `Workspace`
+ * plus a conversation count.
+ */
+export interface WorkspaceEntry extends Workspace {
+ /** Number of conversations assigned to this workspace. */
+ readonly conversationCount: number;
+}
+
+// ─── Computers ───────────────────────────────────────────────────────────────
+
+/**
+ * A read-only view of a remote computer discovered from the system's
+ * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity
+ * (there is no Computer CRUD store). To add a computer, the user adds a `Host`
+ * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()`
+ * read. Every field below is resolved from the config (first-match-wins for
+ * `HostName`/`User`/`Port`/`IdentityFile`).
+ *
+ * `alias` is the `computerId` users select — the string persisted per
+ * conversation and per workspace (the computer analog of `cwd`). `knownHost`
+ * drives the frontend "known/new" indicator and is read-only.
+ */
+export interface Computer {
+ /** The SSH config `Host` alias — also the `computerId` users select. */
+ readonly alias: string;
+ /** Resolved `HostName`/IP from the config (falls back to the alias itself). */
+ readonly hostName: string;
+ /** Resolved port (config `Port`, default 22). */
+ readonly port: number;
+ /** Resolved user (config `User`, default the current user). */
+ readonly user: string;
+ /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */
+ readonly identityFile: string | null;
+ /**
+ * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously
+ * connected). Drives the frontend "known/new" indicator. Read-only.
+ */
+ readonly knownHost: boolean;
+}
+
+/**
+ * A computer entry in the list response (`GET /computers`) — a `Computer` plus
+ * a usage count. Parallel to `WorkspaceEntry`.
+ */
+export interface ComputerEntry extends Computer {
+ /** Number of conversations/workspaces whose `computerId` resolves to this alias. */
+ readonly usageCount: number;
+}
```