diff --git a/AGENTS.md b/AGENTS.md
index fd8cd8b9..02302380 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -200,12 +200,30 @@
- **Page header → content spacing**: `PageHeader` with `density="shell"` uses `pb-6` (24px) as bottom padding. This is the canonical gap between the title + subtitle block and whatever sits below it (tabs, content cards, etc.). All workspace panel pages must share this same spacing so the visual rhythm stays consistent. Do not override `pb-6` with ad-hoc margins or padding on the `PageHeader`; if a page needs tabs, place the `TabsList` directly after `PageHeader` — the header's built-in bottom padding provides the gap.
- **Selection check icon placement**: in selectable lists (model pickers, dropdown items, option lists), the check/checkmark icon for the selected item must always appear on the **right (trailing) side** of the row, never on the left. The left side is reserved for the item's icon/logo. Layout: `[icon/logo] [label] [meta] [check ✓]`. This keeps the visual hierarchy consistent — icons identify, checks confirm. Style the check icon with `strokeWidth={3}` and `text-text-heading` (bold + darkest color) so it reads clearly at small sizes.
- **Dropdown list hover style**: all clickable rows inside dropdown menus, popovers, and select panels must use `rounded-lg hover:bg-surface-2` for their hover state. Do not mix rounded and square hover backgrounds within the same dropdown, and do not use opacity variants like `bg-surface-2/50` — keep hover fills consistent and solid.
+- **Compact nav list density** (channels, DMs, workspace lists, sidebar sections): use a tight vertical rhythm so rows read as one group, not a chain of pills. The canonical spec:
+ - Scroll container: `px-2` (8px horizontal) so selected/hover rounded fills float 8px from the sidebar edge — **never** let the fill hug the panel wall.
+ - Row gap: **`space-y-0.5`** (2px) — tight but not touching. This is the minimum gap that prevents adjacent filled states (hover + hover, focus-ring + hover, unread-bg + hover, selected + hover) from visually butting into each other when more than one row is styled at the same time. Do **not** use `space-y-1` (4px) or larger — that breaks the rows into disconnected pills. Do **not** use `space-y-0` — even though a single hovered row looks fine, two adjacent filled states glue together and look like one smudged block.
+ - Row radius: `rounded-md` (8px) — `rounded-lg` (12px) looks pill-like inside narrow (≤240px) sidebars.
+ - Row padding: `pl-2 pr-2 py-2` (8px vertical) with `gap-2.5` between icon and label, 13px text + 14px icon (`h-3.5 w-3.5`) yields a ~32px row height — comfortable breathing room without feeling floppy, matches Discord density. Use a 14px icon (not 16px) so the icon doesn't dominate 13px label text in narrow sidebars.
+ - Unread / count badge: `h-4 min-w-4 rounded-full text-[10px] px-1` — keep it compact (16px) so it doesn't visually push row content or make rows feel crowded. Avoid the larger 18px badge inside a compact nav list.
+ - Section header: `px-2 pt-3 pb-2` with `text-[11px] font-semibold uppercase tracking-wider text-nav-muted`. `pt-3` (12px) gives breathing room between sections; `pb-2` (8px) gives a clear gap between the decorative label and the first filled row so the label doesn't visually "touch" hover/selected backgrounds below it. Do not reduce to `pb-1` — 4px is not enough when the first row is hovered. 11px reads clearly at narrow sidebar widths without competing with the 13px row labels; 10px is too small and disappears.
+ - Section header affordance button (e.g. the "+" to create a new channel): `h-6 w-6 rounded-md p-0` (24px hit area) with a `h-3.5 w-3.5` (14px) icon. Do not use `h-4 w-4` (16px) — the hover fill is too cramped to read as a real button and the tap target is below the accessible minimum.
+ - Search / filter input at the top of the list: its wrapper container uses the **same `px-2`** as the list container below, so the input's horizontal bounds align with the selection/hover fills of the rows — a visual rhythm the user immediately reads as "this search filters this list". Mismatched insets (e.g. `px-3` search over `px-2` list) make the input look like a different component. Input styling: `h-8 border-border-subtle bg-nav-input`, focus state switches to `focus-within:border-transparent focus-within:ring-1` with the app's nav ring color. Do **not** use `border-transparent` at rest — a sidebar search without a visible border reads as a passive pill and loses affordance.
+ - Section header label language: **decorative uppercase category labels stay English across all locales** (e.g. "CHANNELS" / "PINNED" / "DIRECT MESSAGES"). The `uppercase tracking-wider` styling is inherently English-first — CJK characters do not respond to `text-transform: uppercase`, and the wide letter-spacing looks wrong on CJK glyphs. Hardcode these strings rather than routing them through i18n. This matches the convention used by Slack / Cursor / Linear in their CJK locales. If a design explicitly needs localized category labels, drop `uppercase tracking-wider` and use sentence-case styling instead.
+ - Apply the same paddings (`rounded-md pl-2 pr-2 py-[3px]`) to affordance rows at the bottom of a section (e.g. "Add channels", "New thread") so they stay visually part of the same list.
+ - This density applies to channel lists, DM lists, workspace switchers, nested nav trees, and similar row-based sidebar surfaces. It does **not** apply to content lists (cards, settings rows) — those keep the looser `space-y-2` / `space-y-4` rhythm defined in "Spacing scale".
+- **Selection row background**: selected rows in navigation bars, sidebars, channel lists, dropdowns, command menus, picker lists, and similar row-based surfaces must use a **neutral** background — `bg-surface-3` (one step darker than the hover's `bg-surface-2`) — paired with `text-text-heading` and `font-semibold`. **Never** fill a persistent selection with `--color-brand-primary`, `--color-accent`, or any semantic color token (warning, info, success, error) — those tokens are reserved for small accented badges, filled CTA buttons, links, focus rings, and status indicators. Emphasis on selected rows comes from **weight + text color contrast**, not from a colored fill. The same rule applies to app-level CSS variables (e.g. `--slark-color-nav-active`): map them to `--color-surface-3` / `--color-text-heading`, not to `--color-brand-primary` / `--color-accent`. **Exception** — only when a row represents a transient or real-time "spotlight" state (e.g. a moderation queue row currently being processed, a recording row currently capturing) may a brief brand tint be used, and it should be explicitly justified in the PR description. A persistent selected state is always neutral.
+- **Row-level hover fill**: hover backgrounds on list rows, title buttons, header affordances, and any other interactive row-shaped target must use `hover:bg-surface-2` (neutral). **Never use `hover:bg-accent`** — Tailwind's `bg-accent` resolves to `hsl(var(--accent))` which is the raw brand **teal**, not our design-system `--color-accent` (near-black). Applying `hover:bg-accent` to a title or row floods the entire surface with teal on hover, violating the same "brand color reserved for accents only" rule as selection backgrounds. If you genuinely want a strong hover emphasis, use `hover:bg-surface-3`; if you want a brand-tinted hover on a single small inline target (e.g. a chip), use `hover:bg-[var(--color-brand-subtle)]`. When reviewing, `grep` new code for `hover:bg-accent` and replace it before landing.
- **Model / list-item tier badges**: when a selectable item is gated behind a subscription tier (e.g. Plus, Pro), display a small pill badge immediately after the item name, before any trailing meta (context window, price, check icon). Badge layout per row: `[icon/logo] [label] [tier badge] [meta] [check ✓]`. Style each tier distinctly:
- **Plus** — `bg-[var(--color-brand-subtle)] text-[var(--color-brand-primary)]` (brand teal wash).
- **Pro** — `bg-[var(--color-warning-subtle)] text-[var(--color-warning)]` (warm gold wash).
Badge sizing: `rounded-[4px] px-1.5 py-[1px] text-[9px] font-semibold uppercase leading-tight`. The text is the tier name in uppercase (`PLUS` / `PRO`). Items with no `tier` value show no badge (free / included in all plans).
- **Settings row title font size**: setting row titles (e.g. "Launch at startup", "Usage analytics") use `text-[13px]` (`--text-size-base`), not 12px. These are primary labels and need the same body-text size for readability. Only descriptions/hints below the title use the smaller `text-[12px]`.
- **Settings row pattern**: within settings/preference pages, each setting item is a single horizontal row — `[title + description]` left-aligned, `[control (Switch / Select / Button)]` right-aligned — using `flex items-start justify-between gap-4`. Multiple rows within a section use `divide-y divide-border` for separation.
+- **Chat-feed content-block widths** — every rich block rendered inside a chat message must belong to one of two predictable width tiers, so the conversation reads as a single vertical rhythm instead of a wobble of ad-hoc sizes:
+ - **Card tier — `w-full max-w-[640px]`**: all "structured" cards in the feed — `CodeBlock` (collapsed + `CollapsedContentRow`), `DiffBlock`, `ToolResultBlock`, `ActionCard`, `ApprovalBlock`, `ProgressBlock`, `TopicCard`. These blocks carry titles, statuses, or multi-line details and need the horizontal room.
+ - **Attachment tier — `w-[360px] max-w-full`**: all single-item media/file attachments — `FileAttachment`, `ImageAttachment` (default `width={360} height={220}`), `VideoAttachment`, `VoiceMessage`. They share the same frame regardless of payload so voice / file / image / video line up identically when stacked. `ImageGallery` is the one exception (3-column grid) and keeps its own `max-w-[480px]` so each thumbnail stays legible.
+ - Do not hand-tune these widths per story or per message. If a new block needs a different size, decide explicitly which tier it belongs to and reuse the tier's width; do not introduce a third lane.
- Action buttons (Save, Confirm, Submit) default to the **right** side of their container.
- In horizontal form rows the confirm button sits at the trailing (right) edge; in vertical stacks it right-aligns via `flex justify-end` or `ml-auto`.
- Cancel / secondary actions appear to the **left** of the primary confirm button.
@@ -268,7 +286,7 @@
| `error` / `error-subtle` | Validation errors, failures, destructive emphasis |
| `info` / `info-subtle` | Informational callouts, tips, neutral highlights |
- **Brand color** (`--color-brand-primary`) — links, focus rings, accented badges, brand emphasis. Do not use for status.
-- **Accent color** (`--color-accent`) — primary interactive surfaces (filled buttons, toggles). Use `--color-accent-fg` for text on accent backgrounds.
+- **Accent color** (`--color-accent`) — primary interactive surfaces (filled buttons, toggles). Use `--color-accent-fg` for text on accent backgrounds. **Never use `bg-accent` / `hover:bg-accent` (or `/50`, `/40`, `/30` opacity variants) as a hover background on outline buttons, ghost buttons, list rows, menu items, or cards** — `--color-accent` is near-black in light mode and produces a heavy, filled affordance that reads as the primary action. Neutral hover fills must use `hover:bg-surface-2`; dropdown/menu rows must use `rounded-lg hover:bg-surface-2`; destructive-intent hover must use `hover:bg-destructive/10 hover:text-destructive` (see "Button variant selection").
- **Neutral text colors** — follow the hierarchy in "Typography hierarchy" above; never use raw hex/rgb.
- **Surface colors** — use the numbered scale in order: `surface-0` (page bg) → `surface-1` (cards) → `surface-2` (hover/secondary) → `surface-3` (dividers/tertiary). Do not skip levels.
- Do not mix semantic colors for decoration; they must convey meaning.
@@ -306,6 +324,26 @@
- Elevation should increase with z-index: page content → cards → popovers → modals.
- Match `border-radius` to context: `--radius-md` for controls, `--radius-lg` for cards, `--radius-xl` for large panels.
+### Frosted glass (translucent surfaces)
+- Use the frosted-glass pattern for **chrome that floats over content** — sticky nav bars, activity bars, floating toasts/popovers — not for regular content panels.
+- Canonical recipe: `bg-surface-0/85 backdrop-blur-md border border-border` (stacked nav/landing) or `bg-surface-1/80 backdrop-blur-md border border-border-subtle` (sidebar/activity bar over macOS vibrancy). Preserve the panel's native surface tone by using that surface at the alphas below instead of switching to a different color.
+- **Always start from `--color-surface-1` (white), never `--color-surface-2` (gray)**, for frosted chrome in a light theme. `surface-2` is a gray token — at 50–70% alpha it reads as a dim gray wash even against a white desktop, making the chrome look dirty/dead instead of translucent. `surface-1` (white/card) at 75–85% alpha reads as "lightly frosted white" which is what Slack / Cursor / Finder sidebars look like. The same principle: if the solid fallback of your surface looks gray, the translucent version will look grayer.
+- Alpha range depends on whether native vibrancy sits behind the surface:
+ - **Over native vibrancy (Electron macOS with `vibrancy` set)**: 75–85% — this range lets a hint of the desktop shine through via blur without dragging the chrome grayer than the desktop. Going below 70% over vibrancy turns the sidebar visibly gray/dim (the vibrancy tint dominates), which users read as "something is wrong" rather than "frosted glass". If you want a more prominent desktop-blur feel, prefer moving `BrowserWindow` to a lighter `vibrancy` mode over dropping alpha further.
+ - **Over in-window content only (no native vibrancy)**: 70–92% — higher alpha keeps legibility since there's no real desktop blur, just blurred in-window content.
+ - Avoid `backdrop-saturate-*` unless you have a specific reason — saturating a blurred desktop wallpaper under a gray-ish frosted surface tends to deepen the perceived gray, not brighten it.
+- Always pair translucency with `backdrop-blur-md` (medium blur). Heavier blurs (`backdrop-blur-xl`) are reserved for over-modal overlays and command palettes. Lighter blurs (`backdrop-blur-sm`) look muddy.
+- Always add a subtle border (`border-border-subtle` for in-app chrome, `border-border` for landing-page chrome) — without a border, translucent surfaces bleed into neighbors and lose their edge.
+- **Electron integration gotcha — the full parent chain must be transparent.** `backdrop-filter` / `backdrop-blur-*` only blurs what is **behind** the element in the same compositing layer. If **any** ancestor (html, body, #root, AppLayout root) has a solid background, that solid color covers the native vibrancy and the blur has nothing to reveal — the effect silently collapses to a flat tint. The Slark app enables this by:
+ 1. Setting `vibrancy: "sidebar"` and `visualEffectState: "active"` on `BrowserWindow` (darwin only). See `apps/slark/src/main/index.ts`.
+ 2. Not setting a solid `backgroundColor` on macOS windows (a solid bg suppresses vibrancy just like a solid DOM bg does). Non-mac platforms still set a solid `backgroundColor` so there's no black flash on startup.
+ 3. Keeping `html`, `body`, and `#root` at `background: transparent` (see `apps/slark/src/renderer/src/app/globals.css`) so vibrancy propagates up to the translucent chrome.
+ 4. Removing `bg-background` from the outermost layout container (e.g. `AppLayout`'s root `
`).
+ 5. Leaving the content panels (`Sidebar`, main chat view) with their own **opaque** `bg-nav` / `bg-surface-1` so only the chrome strip is translucent — long-form reading surfaces stay fully opaque.
+- Do **not** apply this pattern to content panels (chat body, page cards, settings forms). Those are content-bearing surfaces and should stay fully opaque so text remains maximally readable.
+- If you ever add a new top-level container above the ActivityBar, make sure it stays `background: transparent`; accidentally adding `bg-background` or `bg-surface-0` to a parent is the #1 way to silently break this effect.
+- **The inverse gotcha — content panels must set their own opaque bg explicitly.** Because `html / body / #root / AppLayout` are transparent, any view that forgets `bg-surface-1` will silently show the native vibrancy through (it looks "frosted" but it's actually a bug — reading long-form text over vibrancy is uncomfortable and breaks the content/chrome distinction). The Slark app sets `bg-surface-1` on `AppLayout`'s `` so every routed view inherits a solid white content canvas by default; individual views should not remove it. If a view truly wants to opt into the frosted chrome look (rare), it should still wrap its long-form text in an opaque inner panel.
+
## Accessibility and UX expectations
- Accessibility is actively tested with `vitest-axe`.
- Prefer semantic roles and label associations that work with Testing Library queries.
@@ -468,6 +506,9 @@ Use this section when consuming `@nexu-design/ui-web` components. For exhaustive
- **Composition:** `Tabs > TabsList > TabsTrigger` + `TabsContent`
- **All triggers use `font-semibold`** by default (both active and inactive) to prevent width shift on selection. The active tab is distinguished by `bg-white` fill against the `bg-surface-2` list background.
- **Page-level tabs must include icons**: when a tab controls a large content area (most or all of the page changes on switch — e.g. Settings "General" / "AI Model Providers", Skills "Yours" / "Explore"), each `TabsTrigger` must include a leading icon (`size={14}`) to reinforce the category at a glance. Use the built-in `gap-1.5` on the trigger to space icon and label. Reserve text-only tabs for lightweight, in-section switching where icons would add visual noise.
+- **Tab labels stay English across locales** for page-level navigation tabs (chat channel header tabs, settings nav tabs, skills nav tabs). These labels are short orientation markers — the same convention as decorative uppercase category labels (CHANNELS / PINNED / DIRECT MESSAGES). Hardcode them as English string literals rather than routing through i18n: CJK translations tend to be longer, breaking compact tab bars visually, and the tab icons already do most of the semantic work. If a particular tab genuinely needs localization, scale the tab container (not the individual triggers) and document the exception.
+- **Compact tab size inside a chat/channel header**: when a tabs row sits inside a chrome header (directly under a channel title or page title), use a compact trigger size — `TabsList` `h-7 rounded-md p-0.5`, `TabsTrigger` `h-6 gap-1 px-2 text-[12px] font-semibold` with `size-3` (12px) icons. Full-size tabs (`text-sm`, `size-3.5` icons) look overweight when paired with a 15–16px channel title on the row above.
+- **Unified chat/channel header (title + tabs in one block)**: when a channel header has both a title row and a tabs row, they must read as a **single** chrome surface — do not place a `border-b` between them. Wrap the title `WindowChrome` and the `TabsList` inside one container with a single bottom border, e.g. `
[title row][tabs list]
`. A divider between the two rows reads as "two stacked toolbars" and wastes vertical space on what is conceptually one header. The single bottom border separates chrome from content.
### TextLink
- **Variants:** `default`, `muted`
diff --git a/apps/slark/electron.vite.config.ts b/apps/slark/electron.vite.config.ts
index 5204cae0..d9f17d44 100644
--- a/apps/slark/electron.vite.config.ts
+++ b/apps/slark/electron.vite.config.ts
@@ -14,6 +14,15 @@ export default defineConfig({
resolve: {
alias: {
"@": resolve("src/renderer/src"),
+ // Point ui-web at its source during dev so edits to primitives/patterns
+ // reflect instantly via HMR, and so the classes Tailwind scans in
+ // packages/ui-web/src/**/*.{ts,tsx} are the same ones actually rendered
+ // at runtime. Without this the renderer consumes the stale dist bundle
+ // (see packages/ui-web/package.json "main": "./dist/index.js") and any
+ // new utility classes in a primitive silently never reach the DOM,
+ // even though Tailwind still generates CSS for them. Storybook already
+ // does the same remap in apps/storybook/.storybook/main.ts.
+ "@nexu-design/ui-web": resolve(__dirname, "../../packages/ui-web/src/index.ts"),
},
},
plugins: [react(), tailwindcss()],
diff --git a/apps/slark/src/main/index.ts b/apps/slark/src/main/index.ts
index 91d085d6..6459f716 100644
--- a/apps/slark/src/main/index.ts
+++ b/apps/slark/src/main/index.ts
@@ -36,6 +36,8 @@ function handleDeepLink(url: string): void {
}
function createWindow(): void {
+ const isMac = process.platform === "darwin";
+
const mainWindow = new BrowserWindow({
width: 1280,
height: 800,
@@ -44,7 +46,15 @@ function createWindow(): void {
show: false,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 14 },
- backgroundColor: "#09090b",
+ // On macOS we opt into native sidebar vibrancy so the ActivityBar and any
+ // translucent chrome can show real frosted-glass (blurs the desktop behind
+ // the window). `visualEffectState: "active"` keeps the blur active even when
+ // the window loses focus, matching the look of Slack / Cursor / Finder.
+ // Non-mac platforms fall back to a solid light background that matches
+ // --color-surface-0 so there's no black flash on startup.
+ ...(isMac
+ ? { vibrancy: "sidebar" as const, visualEffectState: "active" as const }
+ : { backgroundColor: "#fafafa" }),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
diff --git a/apps/slark/src/renderer/index.html b/apps/slark/src/renderer/index.html
index 43284dcf..82b9189f 100644
--- a/apps/slark/src/renderer/index.html
+++ b/apps/slark/src/renderer/index.html
@@ -23,7 +23,7 @@
})()
-
+
diff --git a/apps/slark/src/renderer/src/app/App.tsx b/apps/slark/src/renderer/src/app/App.tsx
index a71cfbc1..b64a93e6 100644
--- a/apps/slark/src/renderer/src/app/App.tsx
+++ b/apps/slark/src/renderer/src/app/App.tsx
@@ -87,8 +87,8 @@ export function App(): React.ReactElement {
) : (
}>
- } />
- } />
+ } />
+ } />
} />
} />
} />
@@ -98,7 +98,7 @@ export function App(): React.ReactElement {
} />
} />
- } />
+ } />
)}
diff --git a/apps/slark/src/renderer/src/app/globals.css b/apps/slark/src/renderer/src/app/globals.css
index 3b3d264f..c46c6c78 100644
--- a/apps/slark/src/renderer/src/app/globals.css
+++ b/apps/slark/src/renderer/src/app/globals.css
@@ -5,21 +5,48 @@
@custom-variant dark (&:is(.dark *));
+/*
+ * Root containers stay transparent so any translucent chrome (ActivityBar,
+ * floating toasts) can show the BrowserWindow's native macOS vibrancy through
+ * the parent chain. On non-mac the BrowserWindow's backgroundColor (set in
+ * main/index.ts) provides the solid fallback. Content panels (Sidebar, chat
+ * main) keep their own opaque bg so long-form text stays maximally readable.
+ */
+html,
+body,
+#root {
+ background: transparent;
+}
+
:root {
--slark-color-nexu-primary: oklch(0.65 0.2 260);
--slark-color-nexu-agent: oklch(0.7 0.18 160);
--slark-color-nexu-runtime: oklch(0.75 0.15 55);
+ /* User presence tokens — the app standardises on three states:
+ online (green), away (yellow, also used for DND), offline (gray).
+ `nexu-busy` stays around for legacy / agent-status call sites that
+ want a warmer "working / DND" hue; new code should prefer one of
+ the three user-presence tokens above. */
--slark-color-nexu-online: oklch(0.72 0.19 145);
+ --slark-color-nexu-away: oklch(0.82 0.16 90);
--slark-color-nexu-busy: oklch(0.7 0.2 30);
- --slark-color-nexu-offline: oklch(0.55 0 0);
+ --slark-color-nexu-offline: oklch(0.65 0 0);
--slark-color-nav: var(--color-surface-1);
--slark-color-nav-surface: var(--color-surface-2);
--slark-color-nav-hover: var(--color-surface-2);
- --slark-color-nav-active: var(--color-brand-primary);
- --slark-color-nav-active-fg: var(--color-accent-fg);
- --slark-color-nav-active-soft: color-mix(in srgb, var(--color-accent-fg) 18%, transparent);
- --slark-color-nav-active-muted: color-mix(in srgb, var(--color-accent-fg) 78%, transparent);
+ /*
+ * Selected navigation row follows the restrained Slack / Cursor pattern:
+ * a neutral surface-3 fill (one step darker than hover's surface-2) plus
+ * text-heading (near-black) text and font-semibold weight. No brand color
+ * and no filled accent — per AGENTS.md, --color-brand-primary is reserved
+ * for links / focus rings / accented badges, and --color-accent is reserved
+ * for primary interactive surfaces like filled buttons.
+ */
+ --slark-color-nav-active: var(--color-surface-3);
+ --slark-color-nav-active-fg: var(--color-text-heading);
+ --slark-color-nav-active-soft: color-mix(in srgb, var(--color-text-heading) 18%, transparent);
+ --slark-color-nav-active-muted: color-mix(in srgb, var(--color-text-heading) 78%, transparent);
--slark-color-nav-fg: var(--color-text-heading);
--slark-color-nav-muted: var(--color-text-secondary);
--slark-color-nav-border: var(--color-border-subtle);
@@ -32,6 +59,7 @@
--color-nexu-agent: var(--slark-color-nexu-agent);
--color-nexu-runtime: var(--slark-color-nexu-runtime);
--color-nexu-online: var(--slark-color-nexu-online);
+ --color-nexu-away: var(--slark-color-nexu-away);
--color-nexu-busy: var(--slark-color-nexu-busy);
--color-nexu-offline: var(--slark-color-nexu-offline);
diff --git a/apps/slark/src/renderer/src/components/agents/AgentsSidebar.tsx b/apps/slark/src/renderer/src/components/agents/AgentsSidebar.tsx
index 3d4f9476..8d542a7f 100644
--- a/apps/slark/src/renderer/src/components/agents/AgentsSidebar.tsx
+++ b/apps/slark/src/renderer/src/components/agents/AgentsSidebar.tsx
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { Plus, Search, Users as UsersIcon, Bot, UserPlus } from "lucide-react";
+import { Plus, Search, Bot, UserPlus } from "lucide-react";
import { Button, Input, cn } from "@nexu-design/ui-web";
-import { useT } from "@/i18n";
import { useAgentsStore } from "@/stores/agents";
+import { presenceDotClass, presenceLabel } from "@/lib/user-presence";
import { mockAgents, mockAgentTemplates, mockUsers } from "@/mock/data";
import { CreateAgentDialog } from "./CreateAgentDialog";
import { InvitePeopleDialog } from "@/components/chat/InvitePeopleDialog";
@@ -13,7 +13,6 @@ import type { Agent, User } from "@/types";
export function AgentsSidebar(): React.ReactElement {
const navigate = useNavigate();
- const t = useT();
const { memberId } = useParams();
const { agents, setAgents, setTemplates, selectAgent } = useAgentsStore();
const [showCreateAgent, setShowCreateAgent] = useState(false);
@@ -70,14 +69,17 @@ export function AgentsSidebar(): React.ReactElement {
+ {/* Outline variant: white fill + subtle border + surface-2 hover.
+ This gives the Add-teammate action a clear visual weight and
+ keeps it distinct from the adjacent gray-filled Search input. */}
{showAddMenu && (
@@ -89,10 +91,10 @@ export function AgentsSidebar(): React.ReactElement {
}}
variant="ghost"
size="inline"
- className="h-auto w-full justify-start rounded-md px-2.5 py-2 text-xs text-left text-foreground hover:bg-accent hover:text-foreground"
+ className="h-auto w-full justify-start rounded-md px-2.5 py-2 text-xs text-left text-foreground hover:bg-surface-2 hover:text-foreground"
leadingIcon={}
>
- {t("team.invitePerson")}
+ Invite person
-
+ {/* `pt-2` gives the first section header visible breathing room
+ below the Search input — flush against the input block it felt
+ cramped and made the header read as input meta. */}
+
{filteredUsers.length > 0 && (
+ {/* Section headers drop their leading icon — the uppercase
+ label already reads as a header and the icon was adding
+ visual noise in a narrow sidebar. */}
-
- {t("team.people")}
+ Members
{filteredUsers.length}
@@ -149,27 +156,50 @@ export function AgentsSidebar(): React.ReactElement {
: "text-nav-muted hover:bg-nav-hover hover:text-nav-fg",
)}
>
-
+ {/* Avatar + presence dot overlay. The ring keeps the photo
+ readable on surface hover fills; the small colored dot
+ in the bottom-right carries the member's live status
+ (online / away / offline) using the shared presence
+ palette. A `border-nav` ring on the dot cuts it out
+ cleanly from whichever avatar it's sitting on. */}
+
+
+
+
{user.name}
{user.role === "owner" && (
-
- {t("team.role.owner")}
+ /* Owner tag always reads as a brand-accented label
+ (brand-primary on brand-subtle), selected row or
+ not — matches the larger Owner badge on the
+ profile detail header for visual consistency. */
+
+ Owner
)}
diff --git a/apps/slark/src/renderer/src/components/agents/UserDetail.tsx b/apps/slark/src/renderer/src/components/agents/UserDetail.tsx
index 2d78cb3c..15cc2635 100644
--- a/apps/slark/src/renderer/src/components/agents/UserDetail.tsx
+++ b/apps/slark/src/renderer/src/components/agents/UserDetail.tsx
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { WindowChrome } from "@/components/layout/WindowChrome";
-import { useT } from "@/i18n";
+import { presenceDotClass, presenceLabel } from "@/lib/user-presence";
import { useChatStore } from "@/stores/chat";
import type { Channel, User } from "@/types";
@@ -13,7 +13,6 @@ interface UserDetailProps {
}
export function UserDetail({ user }: UserDetailProps): React.ReactElement {
- const t = useT();
const navigate = useNavigate();
const channels = useChatStore((s) => s.channels);
const addChannel = useChatStore((s) => s.addChannel);
@@ -59,106 +58,117 @@ export function UserDetail({ user }: UserDetailProps): React.ReactElement {
navigate(`/chat/${newDm.id}`);
};
- const statusLabel =
- user.status === "online"
- ? t("team.status.online")
- : user.status === "away"
- ? t("team.status.away")
- : user.status === "dnd"
- ? t("team.status.dnd")
- : t("team.status.offline");
-
- const statusDot =
- user.status === "online"
- ? "bg-nexu-online"
- : user.status === "away" || user.status === "dnd"
- ? "bg-nexu-busy"
- : "bg-nexu-offline";
+ /* Presence uses the shared helpers from `lib/user-presence` — every
+ surface (sidebar row, profile header, mention cards…) reads the
+ same dot color + label for a given status so the standard stays
+ consistent across the app. */
+ const statusLabel = presenceLabel(user.status);
+ const statusDot = presenceDotClass(user.status);
return (
-
-
+ /* Canonical workspace content-panel layout (AGENTS.md):
+ outer scroll region + inner 800px-capped centered wrapper with
+ shared horizontal padding. Matches SettingsView so profile and
+ settings panels line up to the same gutters instead of one
+ stretching edge-to-edge and the other being centered. */
+
+
+
-
-
-
-
-
+
+ {/* Avatar is now a clean portrait — the presence dot used
+ to sit in the bottom-right but moved below the name
+ (see below) so the status can be read alongside a
+ text label without cluttering the photo. */}
+
-
+ {user.role === "owner" && (
+ /* Owner = link-blue emphasis (brand primary on brand-subtle
+ wash) so the role reads as an accent, not disabled text. */
+
+ Owner
+
+ )}
+
+ {/* Presence line: dot + label, directly under the name. The
+ dot keeps a `role="status"` + `aria-label` so assistive
+ tech still reads the state even when paired with text. */}
+
@@ -250,7 +241,7 @@ export function ChatSidebar(): React.ReactElement {
togglePin(menu.channel.id);
closeCtx();
}}
- className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent transition-colors"
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-surface-2 transition-colors"
>
{pinnedSet.has(menu.channel.id) ? (
<>
@@ -279,14 +270,10 @@ export function ChatSidebar(): React.ReactElement {
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
- title={
- deleteTarget
- ? t("chat.deleteChannelTitle", { name: deleteTarget.name })
- : t("chat.deleteChannel")
- }
- description={t("chat.deleteChannelDesc")}
- confirmLabel={t("common.delete")}
- cancelLabel={t("common.cancel")}
+ title={deleteTarget ? `Delete #${deleteTarget.name}?` : "Delete channel"}
+ description="This will permanently delete the channel and all its messages. This action cannot be undone."
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
confirmVariant="destructive"
onConfirm={handleDeleteConfirm}
/>
diff --git a/apps/slark/src/renderer/src/components/chat/ChatView.tsx b/apps/slark/src/renderer/src/components/chat/ChatView.tsx
index 28a3fabc..142ec051 100644
--- a/apps/slark/src/renderer/src/components/chat/ChatView.tsx
+++ b/apps/slark/src/renderer/src/components/chat/ChatView.tsx
@@ -1,15 +1,23 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
-import { MessageSquare, Bot, UserPlus, Globe, AtSign } from "lucide-react";
+import { AtSign, Bot, FolderOpen, Globe, MessageSquare, Sparkles, Users } from "lucide-react";
+import { EmptyState, Tabs, TabsContent, TabsList, TabsTrigger, cn } from "@nexu-design/ui-web";
+
import { useT } from "@/i18n";
import { useChatStore } from "@/stores/chat";
import { useWorkspaceStore } from "@/stores/workspace";
import { useAgentsStore } from "@/stores/agents";
import { mockMessages, mockChannels, resolveRef, getNexuIntroResponse } from "@/mock/data";
+import type { ContentBlock } from "@/types";
import { WindowChrome } from "@/components/layout/WindowChrome";
import { MessageList } from "./MessageList";
import { MessageInput } from "./MessageInput";
import { AddMembersDialog } from "./AddMembersDialog";
+import { TopicDetailPanel } from "./TopicDetailPanel";
+
+type TopicBlock = Extract;
+
+const TOPIC_PANEL_WIDTH = 380;
export function ChatView(): React.ReactElement {
const t = useT();
@@ -20,6 +28,48 @@ export function ChatView(): React.ReactElement {
const welcomeFired = useRef(false);
const loadedChannels = useRef(new Set());
const [addMembersOpen, setAddMembersOpen] = useState(false);
+ /*
+ * Right-side topic panel state.
+ *
+ * We track two values (not one) so the close animation has something to
+ * render while it collapses: `activeTopic` is the content; `topicPanelOpen`
+ * is the visibility flag that drives the width transition. When the user
+ * closes, we flip `topicPanelOpen` to false immediately (panel slides out)
+ * but keep `activeTopic` until the transition ends so the tabs don't
+ * flash empty. Switching from one topic to another updates `activeTopic`
+ * while keeping the panel open — no close/reopen shimmer.
+ */
+ const [activeTopic, setActiveTopic] = useState(null);
+ const [topicPanelOpen, setTopicPanelOpen] = useState(false);
+
+ const handleTopicOpen = useCallback((block: TopicBlock) => {
+ setActiveTopic(block);
+ setTopicPanelOpen(true);
+ }, []);
+
+ const handleTopicClose = useCallback(() => {
+ setTopicPanelOpen(false);
+ }, []);
+
+ const handleTopicPanelTransitionEnd = useCallback(
+ (event: React.TransitionEvent) => {
+ // Drop topic content only after the collapse animation fully completes
+ // so inner tabs + body don't reflow mid-transition.
+ if (event.propertyName === "width" && !topicPanelOpen) {
+ setActiveTopic(null);
+ }
+ },
+ [topicPanelOpen],
+ );
+
+ // Channel change resets topic panel — a topic from channel A shouldn't
+ // linger when the user navigates to channel B. channelId is the trigger,
+ // not a value consumed inside the effect.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally listed as the trigger; the effect body only calls setters.
+ useEffect(() => {
+ setActiveTopic(null);
+ setTopicPanelOpen(false);
+ }, [channelId]);
useEffect(() => {
if (!channelId) return;
@@ -110,45 +160,151 @@ export function ChatView(): React.ReactElement {
channel.type === "dm" ? channel.members.find((m) => m.id !== "u-1") : undefined;
const otherResolved = otherMember ? resolveRef(otherMember) : undefined;
- return (
-
-
+ // DMs don't carry a roster / files / artifacts story the same way a channel does, so
+ // we keep the old single-pane layout for them and only show tabs for channels.
+ const showTabs = channel.type === "channel";
+
+ // Title-row content is reused by both the tabbed (channel) and DM layouts.
+ // - Hover fill uses surface-2 (neutral) — never bg-accent, which maps to teal
+ // in Tailwind's color vars and would flood the row with brand color on hover.
+ // - Channels show a members chip (Users icon + count) inline right after the
+ // title; it opens the add-members dialog. Descriptions are not rendered in
+ // the header — if a channel needs description context, surface it elsewhere.
+ const headerRow = (
+ <>
+
+
+ {channel.type === "channel" && (
+ )}
+ >
+ );
- {channel.description && (
- {channel.description}
- )}
+ return (
+
+ {showTabs ? (
+
+ {/*
+ Unified chat header: title row + tabs row share one border-b block,
+ with no divider between them. Reads as a single chrome surface
+ instead of two stacked bars.
+
+ Tab labels are intentionally hardcoded English regardless of locale,
+ following the same convention as sidebar section headers
+ (CHANNELS / PINNED). Tabs are for orientation, not user content.
+ */}
+
+
+
+ {/*
+ Split layout for the Messages tab:
+ - Left column (flex-1): the scrollable message list + input.
+ - Right column: TopicDetailPanel in a width-animated wrapper.
+ Uses a plain
instead of ResizablePanel because we need
+ a CSS width transition (200ms ease-standard) for the
+ "push, don't overlay" interaction; ResizablePanel just
+ snaps width. The outer wrapper does the animation, the
+ inner DetailPanel (width: 100%) renders into whatever width
+ the wrapper is currently at.
+ Only shown for channel-type; DMs keep the old single-pane
+ layout below and do not surface topic content blocks.
+ */}
+
+
+
+
+
+ {activeTopic && }
+
+
+
+
+
+ }
+ title="Files"
+ description="No files shared in this channel yet."
+ />
+
+
+
+
+
+ }
+ title="Artifacts"
+ description="Agent artifacts will appear here as your team runs workflows."
+ />
+
{block.status === "pending" && (
+ /*
+ * Reject (secondary/outline) on the left, Approve (primary) on the
+ * right — follows the design-system rule that confirm / primary
+ * actions trail on the right in horizontal groups. Both keep
+ * flex-1 so the row still reads as "pick one of two equal paths"
+ * rather than a weighted CTA with a tucked-away cancel.
+ */
+ {/*
+ * Header — title + compact "X of Y" counter instead of a percentage bar.
+ * Rendering progress as a list of checklist-style steps (Cursor style)
+ * makes the individual substeps the primary readout; a duplicate bar or
+ * big percentage competes with that. The counter keeps completion
+ * legible at a glance without an extra color surface.
+ */}
+
+
{block.title}
- {pct}%
+ {block.current} / {block.total}
-
-
-
-
{block.steps && block.steps.length > 0 && (
-
-
- {indexedSteps.map(({ key, value: step }) => (
-
-
+ {indexedSteps.map(({ key, value: step }) => (
+
+ {/*
+ * Monochrome step icons (Cursor style) — no status colors here
+ * because the label styling (strikethrough + muted / bold /
+ * tertiary) already communicates state. Keeps the card quiet in
+ * a busy chat feed.
+ * - done: Check glyph, muted
+ * - active: spinner, heading color
+ * - pending: empty circle, tertiary color
+ */}
+ {step.status === "done" && (
+
-
- {step.label}
-
-
- ))}
-
-
+ )}
+ {step.status === "active" && (
+
+ )}
+ {step.status === "pending" && (
+ /*
+ * Dashed ring for "not yet started" steps (Cursor style). The
+ * dashed outline reads as "placeholder / outline of a future
+ * step" — distinct from a solid ring which can look like
+ * "selectable / actionable". `strokeDasharray` is set via
+ * inline style because lucide forwards style → the
);
}
+/**
+ * Renders a single work step (description + block) inside an agent-run.
+ * Kept local because the "step" concept only exists for agent-run grouping —
+ * promoting it to ContentBlockRenderer would widen the block union unnecessarily.
+ */
+function AgentRunStepRenderer({
+ step,
+ onExpand,
+}: {
+ step: AgentRunStep;
+ onExpand?: (block: ContentBlock) => void;
+}): React.ReactElement {
+ const handleExpand = (): void => onExpand?.(step.block);
+ let rendered: React.ReactElement;
+ switch (step.block.type) {
+ case "code":
+ rendered = ;
+ break;
+ case "diff":
+ rendered = ;
+ break;
+ case "action":
+ rendered = ;
+ break;
+ case "tool-result":
+ rendered = ;
+ break;
+ case "progress":
+ rendered = ;
+ break;
+ }
+ return (
+
+ {step.description ? (
+
{step.description}
+ ) : null}
+ {rendered}
+
+ );
+}
+
+/**
+ * Agent-run container — consolidates a sequence of work artifacts produced by
+ * one agent into a single collapsible module so the chat stays quiet. Default
+ * view shows only the LAST step (the one currently in flight) expanded; the
+ * completed steps that led up to it live behind a "Show N earlier steps"
+ * toggle so readers who skimmed past earlier can still audit the trail.
+ *
+ * Approval / review blocks deliberately live OUTSIDE this container (see the
+ * `AgentRunStep["block"]` type — approval is not allowed in the narrowed
+ * union). That separation is load-bearing: a run summarizes what the agent
+ * did, an approval demands human attention, and folding the two together
+ * would hide the ask.
+ */
+function AgentRunBlock({
+ block,
+ onExpand,
+}: {
+ block: Extract;
+ onExpand?: (block: ContentBlock) => void;
+}): React.ReactElement {
+ const [showEarlier, setShowEarlier] = useState(false);
+ const steps = block.steps;
+ const earlier = steps.slice(0, -1);
+ const current = steps[steps.length - 1];
+
+ return (
+
+ {earlier.length > 0 ? (
+
+ setShowEarlier((prev) => !prev)}
+ aria-expanded={showEarlier}
+ className={cn(
+ "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-text-secondary transition-colors",
+ // Hover steps up to surface-3 because the shell itself already sits on surface-2 —
+ // using hover:bg-surface-2 would disappear into the background.
+ "hover:bg-surface-3 hover:text-text-primary",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
+ )}
+ >
+
+ {showEarlier
+ ? "Hide earlier steps"
+ : `Show ${earlier.length} earlier step${earlier.length > 1 ? "s" : ""}`}
+
+ {showEarlier ? (
+
+ {earlier.map((step) => (
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+ {current ? : null}
+
+ );
+}
+
export function ContentBlockRenderer({
block,
- isMe,
onApprovalAction,
onExpand,
+ onTopicOpen,
}: ContentBlockRendererProps): React.ReactElement {
const handleExpand = (): void => onExpand?.(block);
@@ -593,7 +688,7 @@ export function ContentBlockRenderer({
case "file":
return ;
case "code":
- return ;
+ return ;
case "action":
return ;
case "tool-result":
@@ -604,7 +699,11 @@ export function ContentBlockRenderer({
return ;
case "progress":
return ;
+ case "agent-run":
+ return ;
case "topic":
- return ;
+ return (
+ onTopicOpen(block) : undefined} />
+ );
}
}
diff --git a/apps/slark/src/renderer/src/components/chat/CreateChannelDialog.tsx b/apps/slark/src/renderer/src/components/chat/CreateChannelDialog.tsx
index d649aff8..8428cb61 100644
--- a/apps/slark/src/renderer/src/components/chat/CreateChannelDialog.tsx
+++ b/apps/slark/src/renderer/src/components/chat/CreateChannelDialog.tsx
@@ -1,11 +1,7 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { ArrowLeft, Bot, Check, Hash, Search, Users } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { Hash } from "lucide-react";
import {
- Avatar,
- AvatarFallback,
- AvatarImage,
- Badge,
Button,
Dialog,
DialogBody,
@@ -14,18 +10,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
- EmptyState,
FormField,
FormFieldControl,
Input,
- InteractiveRow,
- InteractiveRowContent,
- InteractiveRowLeading,
- InteractiveRowTrailing,
- cn,
} from "@nexu-design/ui-web";
-import { useT } from "@/i18n";
import { mockAgents, mockUsers } from "@/mock/data";
import { useAgentsStore } from "@/stores/agents";
import { useChatStore } from "@/stores/chat";
@@ -37,103 +26,56 @@ interface CreateChannelDialogProps {
onCreated?: (channelId: string) => void;
}
-type Step = "details" | "members";
-
+/*
+ * Single-step "Create channel" dialog.
+ *
+ * The previous flow split this into two steps (details → add members), but
+ * channels in a small workspace almost always include everyone anyway, and
+ * the member picker was redundant with the existing "Add members" dialog
+ * reachable from the channel itself. Collapsing it removes a click, a
+ * progress bar, and an entire screen of UI the user mostly clicked through.
+ *
+ * New members flow: on create, we seed membership with ALL users + ALL
+ * agents in the workspace (same default as before). Users can prune
+ * membership later from the channel members panel.
+ *
+ * Copy is hardcoded in English on purpose — the broader app is still
+ * wired through i18n, but this product surface is English-only and the
+ * tokenised subtitle ("Step 1 of 2 — channel details") was the noisiest
+ * side of the old flow.
+ */
export function CreateChannelDialog({
open,
onOpenChange,
onCreated,
}: CreateChannelDialogProps): React.ReactElement {
- const t = useT();
const addChannel = useChatStore((s) => s.addChannel);
const storeAgents = useAgentsStore((s) => s.agents);
const agents = storeAgents.length > 0 ? storeAgents : mockAgents;
- const [step, setStep] = useState("details");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
- const [query, setQuery] = useState("");
- const [selectedUserIds, setSelectedUserIds] = useState(() =>
- mockUsers.map((user) => user.id),
- );
- const [selectedAgentIds, setSelectedAgentIds] = useState([]);
const nameInputRef = useRef(null);
- const searchInputRef = useRef(null);
useEffect(() => {
if (!open) return;
- setStep("details");
setName("");
setDescription("");
- setQuery("");
- setSelectedUserIds(mockUsers.map((user) => user.id));
- setSelectedAgentIds(agents.map((agent) => agent.id));
requestAnimationFrame(() => nameInputRef.current?.focus());
- }, [open, agents]);
-
- useEffect(() => {
- if (step !== "members") return;
- requestAnimationFrame(() => searchInputRef.current?.focus());
- }, [step]);
-
- const filteredUsers = useMemo(() => {
- const normalizedQuery = query.trim().toLowerCase();
- if (!normalizedQuery) return mockUsers;
-
- return mockUsers.filter(
- (user) =>
- user.name.toLowerCase().indexOf(normalizedQuery) !== -1 ||
- user.email.toLowerCase().indexOf(normalizedQuery) !== -1,
- );
- }, [query]);
-
- const filteredAgents = useMemo(() => {
- const normalizedQuery = query.trim().toLowerCase();
- if (!normalizedQuery) return agents;
-
- return agents.filter(
- (agent) =>
- agent.name.toLowerCase().indexOf(normalizedQuery) !== -1 ||
- (agent.description ?? "").toLowerCase().indexOf(normalizedQuery) !== -1,
- );
- }, [agents, query]);
-
- const toggleUser = (id: string): void => {
- setSelectedUserIds((prev) => {
- if (prev.indexOf(id) !== -1) {
- return prev.filter((userId) => userId !== id);
- }
-
- return prev.concat(id);
- });
- };
-
- const toggleAgent = (id: string): void => {
- setSelectedAgentIds((prev) => {
- if (prev.indexOf(id) !== -1) {
- return prev.filter((agentId) => agentId !== id);
- }
-
- return prev.concat(id);
- });
- };
-
- const totalSelected = selectedUserIds.length + selectedAgentIds.length;
-
- const handleNext = (): void => {
- if (!name.trim()) return;
- setStep("members");
- };
+ }, [open]);
const handleCreate = (): void => {
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, "-");
if (!trimmedName) return;
+ // Default membership: everyone in the workspace + every agent. Pruning
+ // happens later via the members panel; at creation the channel should
+ // be immediately usable by the team.
const members: MemberRef[] = [
- ...selectedUserIds.map((id): MemberRef => ({ kind: "user", id })),
- ...selectedAgentIds.map((id): MemberRef => ({ kind: "agent", id })),
+ ...mockUsers.map((user): MemberRef => ({ kind: "user", id: user.id })),
+ ...agents.map((agent): MemberRef => ({ kind: "agent", id: agent.id })),
];
const channel: Channel = {
@@ -152,237 +94,66 @@ export function CreateChannelDialog({
setTimeout(() => onCreated?.(channel.id), 0);
};
- const handleDetailsKeyDown = (event: React.KeyboardEvent): void => {
+ const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === "Enter" && !event.shiftKey && name.trim()) {
event.preventDefault();
- handleNext();
+ handleCreate();
}
};
- const subtitle =
- step === "details"
- ? `${t("createChannel.stepOfTwo", { step: "1" })}${t("createChannel.detailsSuffix")}`
- : `${t("createChannel.stepOfTwo", { step: "2" })}${
- totalSelected === 1
- ? t("createChannel.membersSuffix", { count: String(totalSelected) })
- : t("createChannel.membersSuffixPlural", { count: String(totalSelected) })
- }`;
-
return (
);
}
-
-function SelectionSection({
- label,
- count,
- children,
-}: {
- label: string;
- count: number;
- children: React.ReactNode;
-}): React.ReactElement {
- return (
-
-
-
- {label}
-
- {count}
-
-
{children}
-
- );
-}
diff --git a/apps/slark/src/renderer/src/components/chat/MentionPicker.tsx b/apps/slark/src/renderer/src/components/chat/MentionPicker.tsx
index 9737fcb6..da5cfce8 100644
--- a/apps/slark/src/renderer/src/components/chat/MentionPicker.tsx
+++ b/apps/slark/src/renderer/src/components/chat/MentionPicker.tsx
@@ -45,7 +45,7 @@ export function MentionPicker({
onSelect(m, resolved.name);
onClose();
}}
- className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-accent transition-colors"
+ className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-surface-2 transition-colors"
>
{resolved.name}
diff --git a/apps/slark/src/renderer/src/components/chat/MessageInput.tsx b/apps/slark/src/renderer/src/components/chat/MessageInput.tsx
index 95960a31..b34ffe2a 100644
--- a/apps/slark/src/renderer/src/components/chat/MessageInput.tsx
+++ b/apps/slark/src/renderer/src/components/chat/MessageInput.tsx
@@ -1,8 +1,7 @@
import { Button, cn } from "@nexu-design/ui-web";
-import { Paperclip, Send } from "lucide-react";
+import { ArrowUp, Paperclip, Square } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
-import { useT } from "@/i18n";
import { getRandomAgentResponse, mockAgents } from "@/mock/data";
import { useChatStore } from "@/stores/chat";
import type { Channel, MemberRef, Message } from "@/types";
@@ -14,6 +13,12 @@ interface MessageInputProps {
channel: Channel;
}
+/*
+ * Match the line-box so the placeholder appears vertically centered at rest.
+ * 13px text × 1.5 line-height ≈ 20px; doubled 8px vertical padding = 36px
+ * total, which becomes the textarea's collapsed height without any extra
+ * space above or below the caret.
+ */
const MIN_HEIGHT = 36;
const MAX_HEIGHT = 150;
@@ -22,7 +27,6 @@ export function MessageInput({
isDmWithAgent,
channel,
}: MessageInputProps): React.ReactElement {
- const t = useT();
const [text, setText] = useState("");
const [showMentions, setShowMentions] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
@@ -33,6 +37,14 @@ export function MessageInput({
const pendingDraft = useChatStore((s) => s.pendingDraft);
const setPendingDraft = useChatStore((s) => s.setPendingDraft);
+ /*
+ * Any in-flight agent reply in this channel flips the composer button
+ * from "send" to "stop" (Cursor-style). Selecting on the derived boolean
+ * avoids re-rendering the composer on every token tick — we only care
+ * about the transition true→false.
+ */
+ const isStreaming = useChatStore((s) => (s.messages[channelId] ?? []).some((m) => m.isStreaming));
+
useEffect(() => {
if (pendingDraft) {
setText(pendingDraft);
@@ -71,6 +83,15 @@ export function MessageInput({
let idx = 0;
const tick = (): void => {
+ /*
+ * Cooperative cancellation: the "stop" button flips this message's
+ * isStreaming to false externally. Each tick re-reads the store
+ * and bails early if the flag has flipped, so further tokens
+ * stop being appended.
+ */
+ const current = useChatStore.getState().messages[channelId]?.find((m) => m.id === msgId);
+ if (!current?.isStreaming) return;
+
const chunk = Math.floor(Math.random() * 2) + 1;
idx = Math.min(idx + chunk, tokens.length);
updateMessage(channelId, msgId, {
@@ -131,6 +152,17 @@ export function MessageInput({
adjustHeight(textareaRef.current);
};
+ const handleStop = (): void => {
+ // Flip every streaming message in this channel to done; each token-tick
+ // worker will notice on its next iteration and exit (see `tick` above).
+ const messages = useChatStore.getState().messages[channelId] ?? [];
+ for (const msg of messages) {
+ if (msg.isStreaming) {
+ updateMessage(channelId, msg.id, { isStreaming: false });
+ }
+ }
+ };
+
const handleKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -167,6 +199,9 @@ export function MessageInput({
};
const hasText = text.trim().length > 0;
+ const sendEnabled = hasText || isStreaming;
+ const placeholder =
+ channel.type === "dm" ? `Message ${channel.name}` : `Message #${channel.name}`;
return (
@@ -190,7 +225,7 @@ export function MessageInput({
@@ -201,29 +236,54 @@ export function MessageInput({
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
- placeholder={
- channel.type === "dm"
- ? t("chat.messagePerson", { name: channel.name })
- : t("chat.messageChannel")
- }
+ placeholder={placeholder}
rows={1}
- className="flex-1 resize-none bg-transparent px-1.5 py-1.5 text-[13px] leading-[1.5] text-text-primary placeholder:text-text-muted focus:outline-none"
+ /*
+ * py-2 matches the 20px line-box so the placeholder sits visually
+ * centered in a 36px collapsed composer. Earlier py-1.5 made the
+ * placeholder float above center because the line-box was shorter
+ * than the min-height. Left/right stay at px-1.5 for a subtle
+ * inset against the border.
+ */
+ className="flex-1 resize-none bg-transparent px-1.5 py-2 text-[13px] leading-[1.5] text-text-primary placeholder:text-text-muted focus:outline-none"
/>
-
+
-
-
-
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
diff --git a/apps/slark/src/renderer/src/components/chat/MessageList.tsx b/apps/slark/src/renderer/src/components/chat/MessageList.tsx
index 36a357d3..174ed523 100644
--- a/apps/slark/src/renderer/src/components/chat/MessageList.tsx
+++ b/apps/slark/src/renderer/src/components/chat/MessageList.tsx
@@ -115,6 +115,12 @@ function blockKey(block: ContentBlock): string {
interface MessageListProps {
channelId: string;
channel?: Channel;
+ /**
+ * Invoked when the reader clicks a topic-card content block. Lifted here so
+ * ChatView can own the right-side detail-panel state and animate it in/out
+ * of the chat column (push layout, never overlay).
+ */
+ onTopicOpen?: (block: Extract) => void;
}
const EMPTY_MESSAGES: never[] = [];
@@ -266,7 +272,11 @@ function getQuickPrompts(name: string, templateId: string | null): string[] {
}
}
-export function MessageList({ channelId, channel }: MessageListProps): React.ReactElement {
+export function MessageList({
+ channelId,
+ channel,
+ onTopicOpen,
+}: MessageListProps): React.ReactElement {
const messages = useChatStore((s) => s.messages[channelId] ?? EMPTY_MESSAGES);
const updateMessage = useChatStore((s) => s.updateMessage);
const currentUserId = useWorkspaceStore((s) => s.currentUserId) ?? CURRENT_USER_ID;
@@ -362,6 +372,11 @@ export function MessageList({ channelId, channel }: MessageListProps): React.Rea
reacted: r.users.includes(currentUserId),
}));
+ // Highlight the row when *I* am @mentioned. ChatMessage `highlighted`
+ // renders a subtle row-level tint — that's the design-system's built-in
+ // "this concerns you" affordance, separate from the unread badge.
+ const mentionsMe = msg.mentions.some((m) => m.id === currentUserId);
+
return (
{showDateSeparator && (
@@ -373,6 +388,7 @@ export function MessageList({ channelId, channel }: MessageListProps): React.Rea
sender={sender}
time={formatClock(msg.createdAt)}
compact={isConsecutive}
+ highlighted={mentionsMe}
reactions={reactions.length > 0 ? reactions : undefined}
blocks={
msg.blocks && msg.blocks.length > 0
@@ -380,11 +396,11 @@ export function MessageList({ channelId, channel }: MessageListProps): React.Rea
handleApproval(msg.id, msg.blocks, aid, action)
}
onExpand={setExpandedBlock}
+ onTopicOpen={onTopicOpen}
/>
))
: undefined
diff --git a/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx b/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx
new file mode 100644
index 00000000..8cf7f8fa
--- /dev/null
+++ b/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx
@@ -0,0 +1,389 @@
+import {
+ DetailPanel,
+ DetailPanelCloseButton,
+ DetailPanelHeader,
+ DetailPanelTitle,
+ EmptyState,
+ ImageAttachment,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ cn,
+} from "@nexu-design/ui-web";
+import { Bot, ExternalLink, Hash, MessageSquareMore, Paperclip, Pin, Users } from "lucide-react";
+
+import type { ContentBlock, TopicThreadMessage } from "@/types";
+
+type TopicBlock = Extract;
+
+interface TopicDetailPanelProps {
+ topic: TopicBlock;
+ onClose: () => void;
+}
+
+/**
+ * Right-side detail panel for a clicked topic card.
+ *
+ * Layout contract (see PR #34 rationale, mirrored from the chat-side-panel
+ * storybook prototype):
+ * - Lives inline in the chat column via the parent's flex layout; the
+ * parent animates `width: 0 → 380px` so the message list is *pushed*,
+ * never overlaid. No backdrop, no scrim.
+ * - Primary tab is Thread — the reply conversation under this topic,
+ * with inline images and link-preview cards. Files / Members / Pinned
+ * are secondary. Labels stay English regardless of locale (same rule
+ * as the chat header tabs).
+ * - `bg-surface-0` via DetailPanel (one step below surface-1 chat bg)
+ * gives the panel a subtle depth cue without introducing a new
+ * surface token.
+ */
+
+const STATUS_BADGE: Record<
+ NonNullable,
+ { label: string; className: string }
+> = {
+ active: { label: "Active", className: "bg-info-subtle text-info" },
+ "needs-review": { label: "Needs review", className: "bg-warning-subtle text-warning" },
+ blocked: { label: "Blocked", className: "bg-error-subtle text-error" },
+ done: { label: "Done", className: "bg-success-subtle text-success" },
+ archived: { label: "Archived", className: "bg-surface-2 text-text-tertiary" },
+};
+
+export function TopicDetailPanel({ topic, onClose }: TopicDetailPanelProps): React.ReactElement {
+ const status = topic.status ? STATUS_BADGE[topic.status] : null;
+ const thread = topic.thread ?? [];
+
+ // Aggregate shared media across the thread so the Files tab has
+ // real content when available — matches the user's expectation that
+ // "images and links from the conversation show up here".
+ const sharedImages = thread.filter((m) => !!m.image) as (TopicThreadMessage & {
+ image: NonNullable;
+ })[];
+ const sharedLinks = thread.filter((m) => !!m.link) as (TopicThreadMessage & {
+ link: NonNullable;
+ })[];
+ const hasAnyFiles = sharedImages.length > 0 || sharedLinks.length > 0;
+
+ return (
+
+
+
+ }
+ title="No files"
+ description="Files shared in this topic will appear here."
+ />
+
+ )}
+
+
+
+ {topic.participants.length > 0 ? (
+
+ {topic.participants.map((initials, idx) => (
+
+
+ {initials}
+
+
+ {initials}
+
+
+ ))}
+
+ ) : (
+
+ }
+ title="No members"
+ description="This topic has no participants yet."
+ />
+
+ )}
+
+
+
+ }
+ title="Nothing pinned"
+ description="Pin a message in this topic to keep it handy."
+ />
+
+
+
+ );
+}
+
+/**
+ * Compact reply-thread renderer. Not the main ChatMessage primitive because
+ * the 380px-wide detail panel needs tighter padding, smaller avatars, and
+ * a different affordance for link previews — reusing ChatMessage here would
+ * force us to override half its spacing via className, which is more
+ * brittle than a small local component.
+ *
+ * Top card (optional) shows the topic's preview text as the "opening
+ * context" of the thread when the first reply wasn't authored by the
+ * topic starter. It mirrors how Slack / Linear surface the originating
+ * message at the top of a thread view.
+ */
+function ThreadView({
+ topic,
+ thread,
+}: {
+ topic: TopicBlock;
+ thread: TopicThreadMessage[];
+}): React.ReactElement {
+ if (thread.length === 0) {
+ return (
+
+ {topic.preview ? (
+
+
+ Opening context
+
+
{topic.preview}
+
+ ) : (
+ }
+ title="No replies yet"
+ description="When people reply to this topic, their messages will show up here."
+ />
+ )}
+
- ) : currentSection ? (
+ ) : currentSection && !location.pathname.startsWith("/runtimes") ? (
+ /* The runtimes panel owns its own header row (label + online
+ count on a single line) so we skip the generic label here
+ — otherwise the label sits at a different indent than the
+ content below it. */
-
- {t("runtimes.online", { online: String(onlineCount), total: String(runtimes.length) })}
+ {/* Unified header row — `Runtimes` section label and the `N/M online`
+ meta share one line, at the same `px-3` padding as the tabs and
+ list below, so everything left-aligns cleanly. The `px-1.5` ghost
+ indent the outer Sidebar applies to generic section labels is
+ explicitly suppressed for `/runtimes` so this header takes over. */}
+
+ {/* The label sits on the left and the rescan affordance always
+ occupies the same trailing slot, regardless of state. While
+ `scanning`, the same button renders the spinning glyph and
+ is disabled; idle, it becomes clickable. Previously the
+ spinner lived inline next to the label and a separate
+ button mounted on the right only when idle, which caused
+ the label and icon to jump horizontally on every rescan. */}
-
-
- {t("runtimes.detected")}
-
- {scanning && }
-
- {!scanning && (
-
-
-
- )}
+
+ Detected on this device
+
+
+
+
+ {/* `outline` is the canonical weight for secondary actions
+ (AGENTS.md). The primary black fill was too heavy for
+ a row-level affordance repeated 4 times in a narrow
+ sidebar and made the panel feel shouty. */}
handleAddDetected(d)}
- className="shrink-0 rounded-md bg-nav-active px-2 py-0.5 text-xs font-medium text-nav-active-fg hover:opacity-90"
+ className="shrink-0"
>
- {t("runtimes.add")}
+ Add
);
diff --git a/apps/slark/src/renderer/src/components/runtimes/RuntimesView.tsx b/apps/slark/src/renderer/src/components/runtimes/RuntimesView.tsx
index 4333b55f..e3d5e0b3 100644
--- a/apps/slark/src/renderer/src/components/runtimes/RuntimesView.tsx
+++ b/apps/slark/src/renderer/src/components/runtimes/RuntimesView.tsx
@@ -1,41 +1,50 @@
-import { Button, cn } from "@nexu-design/ui-web";
+import { Button, RuntimeLogo, cn } from "@nexu-design/ui-web";
import {
ArrowRight,
+ ArrowUpRight,
Bot,
- Box,
- Code,
- Cpu,
- ExternalLink,
- MousePointer,
+ Check,
+ Copy,
Play,
RefreshCw,
RotateCw,
- Sparkles,
Square,
- Terminal,
Trash2,
Wifi,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
-import type { ElementType, ReactElement, ReactNode } from "react";
+import type { ReactElement, ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { WindowChrome } from "@/components/layout/WindowChrome";
-import { type TranslationKey, useT } from "@/i18n";
import { useAgentsStore } from "@/stores/agents";
import { useRuntimesStore } from "@/stores/runtimes";
import type { Runtime } from "@/types";
-const typeIcons: Record = {
- "claude-code": Terminal,
- cursor: MousePointer,
- opencode: Code,
- hermes: Cpu,
- codex: Box,
- "gemini-cli": Sparkles,
+/**
+ * Optical size multiplier for each runtime logo.
+ *
+ * All logos render inside a canonical surface tile (size-10 / size-12) with
+ * a target glyph size of ~50% of the tile. A few brand marks — notably
+ * Codex and Gemini CLI — ship with noticeable internal padding baked into
+ * their artwork, so rendering them at the same pixel size as the flush
+ * marks (Claude Code, Cursor, OpenCode) makes them look visibly smaller.
+ *
+ * The factor below is multiplied against the base glyph size so those
+ * padded marks occupy the same perceived area as the flush ones. Keep it
+ * close to `1` — anything over `1.5` starts clipping at the tile edge.
+ */
+const logoOpticalScale: Partial> = {
+ codex: 1.45,
+ "gemini-cli": 1.25,
+ pi: 1.2,
};
+function getLogoSize(base: number, type: InstallGuide["type"]): number {
+ return Math.round(base * (logoOpticalScale[type] ?? 1));
+}
+
const providerLabels: Record = {
"claude-code": "Anthropic",
cursor: "Cursor",
@@ -45,12 +54,6 @@ const providerLabels: Record = {
hermes: "Local",
};
-const statusLabelKeys: Record = {
- connected: "runtimes.statusOnline",
- disconnected: "runtimes.statusOffline",
- error: "runtimes.statusError",
-};
-
const usagePeriods = ["7d", "30d", "90d"] as const;
type UsagePeriod = (typeof usagePeriods)[number];
@@ -300,11 +303,8 @@ function hash(s: string): number {
return Array.from(s).reduce((h, c, i) => (h * 31 + c.charCodeAt(0) + i * 17) % 9973, 7);
}
-function getLastSeen(
- status: Runtime["status"],
- t: (key: TranslationKey, vars?: Record) => string,
-): string {
- if (status === "connected") return t("runtimes.justNow");
+function getLastSeen(status: Runtime["status"]): string {
+ if (status === "connected") return "Just now";
if (status === "error") return "14 min ago";
return "2 hours ago";
}
@@ -314,7 +314,6 @@ function getData(rt: Runtime): RuntimeData {
}
export function RuntimesView(): ReactElement {
- const t = useT();
const navigate = useNavigate();
const { runtimes, selectedRuntimeId, selectRuntime } = useRuntimesStore();
const agents = useAgentsStore((s) => s.agents);
@@ -345,15 +344,14 @@ export function RuntimesView(): ReactElement {
+ {/* Canonical logo tile: fixed-size surface chip with a subtle
+ border frames the brand glyph at ~half the container size,
+ matching the `provider-settings` Storybook pattern in
+ `apps/storybook/src/stories/provider-settings.stories.tsx`. */}
+
+
+
{rt.name}
@@ -375,12 +377,19 @@ export function RuntimesView(): ReactElement {
{rt.status === "connected" ? (
<>
+ {/* Outline icon buttons hover to the neutral surface
+ chip (`hover:bg-surface-2`), NOT `hover:bg-accent`.
+ `--color-accent` is reserved for primary filled
+ affordances; using it as a hover state turns the
+ whole row blue / near-black on mouseover and
+ violates the "at most one accent-weighted action
+ per group" rule in AGENTS.md. */}
@@ -388,8 +397,8 @@ export function RuntimesView(): ReactElement {
type="button"
variant="outline"
size="icon"
- title={t("runtimes.restartRuntime")}
- className="h-8 w-8 flex items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
+ title="Restart runtime"
+ className="h-8 w-8 flex items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-surface-2 transition-colors"
>
@@ -399,7 +408,7 @@ export function RuntimesView(): ReactElement {
type="button"
variant="outline"
size="icon"
- title={t("runtimes.startRuntime")}
+ title="Start runtime"
className="h-8 w-8 flex items-center justify-center rounded-lg border border-border text-nexu-online hover:bg-nexu-online/10 transition-colors"
>
@@ -410,7 +419,7 @@ export function RuntimesView(): ReactElement {
type="button"
variant="outline"
size="icon"
- title={t("runtimes.deleteRuntime")}
+ title="Delete runtime"
className="h-8 w-8 flex items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-destructive hover:border-destructive/30 hover:bg-destructive/10 transition-colors"
>
@@ -420,10 +429,10 @@ export function RuntimesView(): ReactElement {
+
+ {/* Non-interactive content opts out of pointer events so
+ the full card area (outside the copy control) routes
+ clicks through to the docs overlay above. */}
+
+
+
+
-
{g.name}
+
+
{g.name}
+
+
{g.desc}
-
-
- {g.install}
-
-
- {t("runtimes.installationGuide")}
+
+
+ Installation guide:
{g.docsLabel}
-
+
);
})}
- {t("runtimes.autoDetectHint")}
+ Already installed a runtime? Nexu will auto-detect it on next scan.
@@ -789,8 +862,13 @@ function StatCard({ label, value }: { label: string; value: string }): ReactElem
);
}
+const statusLabels: Record = {
+ connected: "Online",
+ disconnected: "Offline",
+ error: "Error",
+};
+
function StatusBadge({ status }: { status: Runtime["status"] }): ReactElement {
- const t = useT();
return (
- {t(statusLabelKeys[status])}
+ {statusLabels[status]}
);
}
diff --git a/apps/slark/src/renderer/src/components/settings/SettingsView.tsx b/apps/slark/src/renderer/src/components/settings/SettingsView.tsx
index 9160d68a..d6fbfe72 100644
--- a/apps/slark/src/renderer/src/components/settings/SettingsView.tsx
+++ b/apps/slark/src/renderer/src/components/settings/SettingsView.tsx
@@ -182,12 +182,11 @@ function WorkspaceTab({
return (
-
- Workspace details
-
- Keep your workspace identity up to date and invite teammates into the shared space.
-
-
+ {/* The card-level "Workspace details" header + description was
+ dropped — the PageHeader already reads "Workspace settings"
+ and the form fields below are self-describing. Repeating a
+ second heading inside the very next card just pushed the
+ actual inputs down with no added information. */}
diff --git a/apps/slark/src/renderer/src/i18n/index.ts b/apps/slark/src/renderer/src/i18n/index.ts
index 12cf5a14..c64ba2dd 100644
--- a/apps/slark/src/renderer/src/i18n/index.ts
+++ b/apps/slark/src/renderer/src/i18n/index.ts
@@ -100,7 +100,6 @@ const en = {
"chat.pinned": "Pinned",
"chat.channels": "Channels",
"chat.directMessages": "Direct Messages",
- "chat.addChannels": "Add channels",
"chat.inviteMembers": "Invite Members",
"chat.createChannel": "Create channel",
"chat.channelLimitReached": "Channel limit reached ({count}/20)",
diff --git a/apps/slark/src/renderer/src/lib/user-presence.ts b/apps/slark/src/renderer/src/lib/user-presence.ts
new file mode 100644
index 00000000..82557da7
--- /dev/null
+++ b/apps/slark/src/renderer/src/lib/user-presence.ts
@@ -0,0 +1,26 @@
+import type { User } from "@/types";
+
+/**
+ * Shared user-presence mapping.
+ *
+ * The app standardises on three visible presence states — green `online`,
+ * yellow `away` (DND collapses into the same color-wise), and gray
+ * `offline`. Keep this helper as the single source of truth so every
+ * surface (sidebar row, profile header, mention hover-card, etc.) ends
+ * up with the same chip color and human label for any given status.
+ *
+ * Tokens are defined in `apps/slark/src/renderer/src/app/globals.css`
+ * as `--color-nexu-online / nexu-away / nexu-offline`.
+ */
+export function presenceDotClass(status: User["status"]): string {
+ if (status === "online") return "bg-nexu-online";
+ if (status === "away" || status === "dnd") return "bg-nexu-away";
+ return "bg-nexu-offline";
+}
+
+export function presenceLabel(status: User["status"]): string {
+ if (status === "online") return "Online";
+ if (status === "away") return "Away";
+ if (status === "dnd") return "Do not disturb";
+ return "Offline";
+}
diff --git a/apps/slark/src/renderer/src/mock/data.ts b/apps/slark/src/renderer/src/mock/data.ts
index ed4a559a..b93c30bb 100644
--- a/apps/slark/src/renderer/src/mock/data.ts
+++ b/apps/slark/src/renderer/src/mock/data.ts
@@ -246,8 +246,8 @@ const agent2Ref: MemberRef = { kind: "agent", id: "a-2" };
export const mockChannels: Channel[] = [
{
id: "ch-welcome",
- name: "welcome",
- description: "Welcome to Nexu! Say hi to your team and agents.",
+ name: "Welcome to Nexu!",
+ description: "",
type: "channel",
members: [user1Ref, user2Ref, user3Ref, agent1Ref, agent2Ref],
lastMessageAt: Date.now() - 60000,
@@ -307,6 +307,29 @@ const MIN = 60 * 1000;
export const mockMessages: Record = {
"ch-showcase": [
+ {
+ id: "sc-0-join",
+ channelId: "ch-showcase",
+ sender: user1Ref,
+ content: "",
+ mentions: [],
+ reactions: [],
+ createdAt: NOW - 26 * 60 * MIN,
+ system: {
+ kind: "join",
+ members: [user2Ref, user3Ref, agent1Ref, agent2Ref],
+ },
+ },
+ {
+ id: "sc-0-kickoff",
+ channelId: "ch-showcase",
+ sender: user1Ref,
+ content:
+ "Spinning up a reference channel with every chat surface the library ships. Poke around and react where something feels off.",
+ mentions: [],
+ reactions: [{ emoji: "👋", users: ["u-2", "u-3"] }],
+ createdAt: NOW - 25 * 60 * MIN,
+ },
{
id: "sc-1",
channelId: "ch-showcase",
@@ -460,101 +483,90 @@ export const mockMessages: Record = {
createdAt: NOW - 32 * MIN,
},
{
- id: "sc-8",
- channelId: "ch-showcase",
- sender: agent1Ref,
- content: "On it. Here's a first pass that honours the jitter policy from the spec:",
- blocks: [
- {
- type: "code",
- language: "typescript",
- filename: "retry.ts",
- code: "export function exponentialBackoff(attempt: number, base = 200): number {\n const ceiling = Math.min(base * 2 ** attempt, 30_000)\n const jitter = Math.random() * ceiling * 0.2\n return Math.floor(ceiling - jitter)\n}\n\nexport async function withRetry(\n fn: () => Promise,\n opts: { max?: number; base?: number } = {},\n): Promise {\n const { max = 5, base = 200 } = opts\n let lastErr: unknown\n for (let i = 0; i < max; i++) {\n try {\n return await fn()\n } catch (err) {\n lastErr = err\n await sleep(exponentialBackoff(i, base))\n }\n }\n throw lastErr\n}",
- },
- ],
- mentions: [],
- reactions: [{ emoji: "👍", users: ["u-2", "u-1"] }],
- createdAt: NOW - 30 * MIN,
- },
- {
- id: "sc-9",
- channelId: "ch-showcase",
- sender: agent1Ref,
- content: "Wiring it into the billing client. Here's the minimal diff:",
- blocks: [
- {
- type: "diff",
- filename: "src/billing/client.ts",
- content:
- '@@ -14,9 +14,13 @@\n import { httpPost } from "../http"\n+import { withRetry } from "./retry"\n \n export async function chargeCustomer(id: string, amount: number) {\n- const res = await httpPost(`/billing/${id}/charge`, { amount })\n+ const res = await withRetry(\n+ () => httpPost(`/billing/${id}/charge`, { amount }),\n+ { max: 5, base: 250 },\n+ )\n if (!res.ok) throw new BillingError(res)\n return res.json()\n }',
- additions: 5,
- deletions: 1,
- },
- ],
- mentions: [],
- reactions: [],
- createdAt: NOW - 28 * MIN,
- },
- {
- id: "sc-10",
- channelId: "ch-showcase",
- sender: agent1Ref,
- content: "",
- blocks: [
- {
- type: "action",
- title: "Running unit tests for billing client",
- description: "pnpm --filter billing test --changed",
- status: "running",
- tool: "pnpm-test",
- },
- ],
- mentions: [],
- reactions: [],
- createdAt: NOW - 26 * MIN,
- },
- {
- id: "sc-11",
- channelId: "ch-showcase",
- sender: agent1Ref,
- content: "",
- blocks: [
- {
- type: "tool-result",
- tool: "pnpm-test",
- input: "pnpm --filter billing test --changed",
- output:
- "PASS src/billing/retry.test.ts\n exponentialBackoff\n ✓ grows exponentially (8 ms)\n ✓ caps at 30s ceiling (3 ms)\n ✓ applies ±20% jitter (12 ms)\n withRetry\n ✓ retries on transient failure (42 ms)\n ✓ throws after max attempts (38 ms)\n\nTest Suites: 1 passed, 1 total\nTests: 5 passed, 5 total\nTime: 0.87 s",
- status: "success",
- },
- ],
- mentions: [],
- reactions: [{ emoji: "✅", users: ["u-1"] }],
- createdAt: NOW - 25 * MIN,
- },
- {
- id: "sc-12",
+ /*
+ * Coder agent's work run — five sequential artifacts (code, diff,
+ * action, tool-result, progress) folded into a single `agent-run`
+ * block. The renderer shows the LAST step expanded ("Staging rollout"
+ * progress, still in flight) and tucks the four completed steps
+ * behind a "Show earlier steps" toggle so the chat stays quiet. The
+ * approval card that follows lives in its own message (sc-13) on
+ * purpose — it still needs to interrupt the reader.
+ */
+ id: "sc-agent-run-1",
channelId: "ch-showcase",
sender: agent1Ref,
content: "",
blocks: [
{
- type: "progress",
- title: "Staging rollout",
- current: 3,
- total: 5,
+ type: "agent-run",
+ id: "run-billing-retry",
steps: [
- { label: "Build artifact", status: "done" },
- { label: "Push image", status: "done" },
- { label: "Canary 10%", status: "done" },
- { label: "Ramp to 50%", status: "active" },
- { label: "Promote to 100%", status: "pending" },
+ {
+ id: "step-retry-ts",
+ description:
+ "On it. Here's a first pass that honours the jitter policy from the spec:",
+ block: {
+ type: "code",
+ language: "typescript",
+ filename: "retry.ts",
+ code: "export function exponentialBackoff(attempt: number, base = 200): number {\n const ceiling = Math.min(base * 2 ** attempt, 30_000)\n const jitter = Math.random() * ceiling * 0.2\n return Math.floor(ceiling - jitter)\n}\n\nexport async function withRetry(\n fn: () => Promise,\n opts: { max?: number; base?: number } = {},\n): Promise {\n const { max = 5, base = 200 } = opts\n let lastErr: unknown\n for (let i = 0; i < max; i++) {\n try {\n return await fn()\n } catch (err) {\n lastErr = err\n await sleep(exponentialBackoff(i, base))\n }\n }\n throw lastErr\n}",
+ },
+ },
+ {
+ id: "step-client-diff",
+ description: "Wiring it into the billing client. Here's the minimal diff:",
+ block: {
+ type: "diff",
+ filename: "src/billing/client.ts",
+ content:
+ '@@ -14,9 +14,13 @@\n import { httpPost } from "../http"\n+import { withRetry } from "./retry"\n \n export async function chargeCustomer(id: string, amount: number) {\n- const res = await httpPost(`/billing/${id}/charge`, { amount })\n+ const res = await withRetry(\n+ () => httpPost(`/billing/${id}/charge`, { amount }),\n+ { max: 5, base: 250 },\n+ )\n if (!res.ok) throw new BillingError(res)\n return res.json()\n }',
+ additions: 5,
+ deletions: 1,
+ },
+ },
+ {
+ id: "step-run-tests",
+ block: {
+ type: "action",
+ title: "Running unit tests for billing client",
+ description: "pnpm --filter billing test --changed",
+ status: "success",
+ tool: "pnpm-test",
+ },
+ },
+ {
+ id: "step-test-result",
+ block: {
+ type: "tool-result",
+ tool: "pnpm-test",
+ input: "pnpm --filter billing test --changed",
+ output:
+ "PASS src/billing/retry.test.ts\n exponentialBackoff\n ✓ grows exponentially (8 ms)\n ✓ caps at 30s ceiling (3 ms)\n ✓ applies ±20% jitter (12 ms)\n withRetry\n ✓ retries on transient failure (42 ms)\n ✓ throws after max attempts (38 ms)\n\nTest Suites: 1 passed, 1 total\nTests: 5 passed, 5 total\nTime: 0.87 s",
+ status: "success",
+ },
+ },
+ {
+ id: "step-rollout",
+ block: {
+ type: "progress",
+ title: "Staging rollout",
+ current: 3,
+ total: 5,
+ steps: [
+ { label: "Build artifact", status: "done" },
+ { label: "Push image", status: "done" },
+ { label: "Canary 10%", status: "done" },
+ { label: "Ramp to 50%", status: "active" },
+ { label: "Promote to 100%", status: "pending" },
+ ],
+ },
+ },
],
},
],
mentions: [],
- reactions: [],
- createdAt: NOW - 22 * MIN,
+ reactions: [{ emoji: "👍", users: ["u-2", "u-1"] }],
+ createdAt: NOW - 30 * MIN,
},
{
id: "sc-13",
@@ -575,6 +587,16 @@ export const mockMessages: Record = {
reactions: [],
createdAt: NOW - 20 * MIN,
},
+ {
+ id: "sc-13b-ping",
+ channelId: "ch-showcase",
+ sender: user2Ref,
+ content:
+ "@Alice Chen eyes on the approval when you're free — graphs look clean but want a second read.",
+ mentions: [user1Ref],
+ reactions: [],
+ createdAt: NOW - 17 * MIN,
+ },
{
id: "sc-14",
channelId: "ch-showcase",
@@ -606,6 +628,51 @@ export const mockMessages: Record = {
isAgent: true,
accent: "var(--color-brand-primary)",
},
+ thread: [
+ {
+ id: "topic-1-r1",
+ author: "Bob Kim",
+ initials: "BL",
+ createdAtLabel: "11 min ago",
+ text: "I caught this in the gateway logs — burst of 500s every 90s, all from the EU pods. Looks like the jitter window collapsed to zero under load.",
+ },
+ {
+ id: "topic-1-r2",
+ author: "Charlie Park",
+ initials: "CP",
+ createdAtLabel: "9 min ago",
+ text: "Confirming — same pattern in Datadog. Attaching the dashboard so everyone's looking at the same slice.",
+ link: {
+ url: "https://app.datadoghq.com/dashboard/billing-retry-storm",
+ title: "Billing · retry storm (EU pods)",
+ description: "P99 latency + 5xx rate grouped by region for the last 24h.",
+ host: "app.datadoghq.com",
+ },
+ },
+ {
+ id: "topic-1-r3",
+ author: "Coder",
+ initials: "CD",
+ isAgent: true,
+ accent: "var(--color-brand-primary)",
+ createdAtLabel: "6 min ago",
+ text: "Drafted a retry helper that caps jitter at 50ms and respects the shared rate-limiter budget. Needs eyes on the policy before I wire it in.",
+ link: {
+ url: "https://github.com/nexu-io/billing/pull/482",
+ title: "billing#482 · introduce jitter-aware retry helper",
+ description:
+ "Adds capped exponential backoff with decorrelated jitter. Gated behind BILLING_RETRY_V2.",
+ host: "github.com",
+ },
+ },
+ {
+ id: "topic-1-r4",
+ author: "Alice Chen",
+ initials: "AC",
+ createdAtLabel: "2 min ago",
+ text: "Policy looks right. Let's ship it behind the flag today and roll to 10% EU tomorrow.",
+ },
+ ],
},
{
type: "topic",
@@ -617,6 +684,42 @@ export const mockMessages: Record = {
replies: 6,
participants: ["BL", "CP", "AC", "DR", "CD", "MN", "KL"],
preview: "Eight variants explored; narrowing to three finalists for Thursday's review.",
+ thread: [
+ {
+ id: "topic-2-r1",
+ author: "Bob Kim",
+ initials: "BL",
+ createdAtLabel: "18 min ago",
+ text: "Dropping the three finalists for the hero. Leaning toward V2 — the gallery grid breathes more.",
+ image: {
+ url: "https://images.unsplash.com/photo-1559028012-481c04fa702d?auto=format&fit=crop&w=880&q=70",
+ alt: "Landing page hero variant exploration",
+ width: 320,
+ height: 200,
+ },
+ },
+ {
+ id: "topic-2-r2",
+ author: "Diana Ramos",
+ initials: "DR",
+ createdAtLabel: "15 min ago",
+ text: "+1 on V2. Agree the grid needs air. Pulled a reference that nails the density we're after.",
+ link: {
+ url: "https://linear.app/nexu/issue/DES-142",
+ title: "DES-142 · hero + gallery density reference",
+ description:
+ "Research bundle + annotated comps from the last round of usability calls.",
+ host: "linear.app",
+ },
+ },
+ {
+ id: "topic-2-r3",
+ author: "Alice Chen",
+ initials: "AC",
+ createdAtLabel: "12 min ago",
+ text: "Works for me. Let's lock V2 and get copy in by Thursday.",
+ },
+ ],
},
{
type: "topic",
@@ -628,6 +731,36 @@ export const mockMessages: Record = {
replies: 3,
participants: ["CP", "AC"],
preview: "All services rotated. Ephemeral tokens now mandatory for internal RPC.",
+ thread: [
+ {
+ id: "topic-3-r1",
+ author: "Charlie Park",
+ initials: "CP",
+ createdAtLabel: "1 h ago",
+ text: "Rotation wrapped at 04:12 UTC. All services on the new key; legacy tokens revoked.",
+ },
+ {
+ id: "topic-3-r2",
+ author: "Charlie Park",
+ initials: "CP",
+ createdAtLabel: "58 min ago",
+ text: "Post-rotation report is up — summary of what changed and the new ephemeral-token policy.",
+ link: {
+ url: "https://notion.so/nexu/auth-rotation-q2-report",
+ title: "Auth rotation — Q2 post-rotation report",
+ description:
+ "Timeline, services touched, and the new ephemeral-token policy for internal RPC.",
+ host: "notion.so",
+ },
+ },
+ {
+ id: "topic-3-r3",
+ author: "Alice Chen",
+ initials: "AC",
+ createdAtLabel: "52 min ago",
+ text: "Nice work. Closing this one out.",
+ },
+ ],
},
{
type: "topic",
diff --git a/apps/slark/src/renderer/src/types/index.ts b/apps/slark/src/renderer/src/types/index.ts
index dd334512..a9dd8450 100644
--- a/apps/slark/src/renderer/src/types/index.ts
+++ b/apps/slark/src/renderer/src/types/index.ts
@@ -135,6 +135,20 @@ export type ContentBlock =
total: number;
steps?: { label: string; status: "done" | "active" | "pending" }[];
}
+ | {
+ /**
+ * Agent work-run: a single collapsible module that wraps a sequence of
+ * work artifacts (code, diffs, actions, tool results, progress) produced
+ * by one agent as it executes a task. Default render shows only the
+ * last/current step; earlier steps collapse behind a "Show N earlier
+ * steps" toggle so the chat stays quiet. Approval / review blocks are
+ * intentionally NOT allowed inside a run — they need their own card
+ * outside this container so they still interrupt the reader.
+ */
+ type: "agent-run";
+ id: string;
+ steps: AgentRunStep[];
+ }
| {
type: "topic";
id: string;
@@ -146,8 +160,50 @@ export type ContentBlock =
participants: string[];
preview?: string;
assignee?: { name: string; isAgent?: boolean; accent?: string };
+ /**
+ * Conversation under this topic — the reply thread shown in the right-
+ * side TopicDetailPanel. Optional because not every topic ships with
+ * canned replies (new topics just show an empty thread state). The
+ * shape is pre-baked for mocks: `createdAtLabel` is already the
+ * display string ("2 min ago") instead of a timestamp, since this
+ * mock data doesn't drive any time-sensitive logic.
+ */
+ thread?: TopicThreadMessage[];
};
+/**
+ * One item inside an `agent-run` block. Each step wraps a work artifact
+ * (code / diff / action / tool-result / progress) and optionally a short
+ * description rendered above it — typically the lead-in sentence the agent
+ * would otherwise say in chat ("On it…", "Wiring it into the billing client…").
+ * We intentionally narrow the block union here so callers can't smuggle
+ * approval or topic cards inside a run; those belong at the message level.
+ */
+export interface AgentRunStep {
+ id: string;
+ description?: string;
+ block: Extract<
+ ContentBlock,
+ | { type: "code" }
+ | { type: "diff" }
+ | { type: "action" }
+ | { type: "tool-result" }
+ | { type: "progress" }
+ >;
+}
+
+export interface TopicThreadMessage {
+ id: string;
+ author: string;
+ initials: string;
+ isAgent?: boolean;
+ accent?: string;
+ createdAtLabel: string;
+ text?: string;
+ image?: { url: string; alt?: string; width?: number; height?: number };
+ link?: { url: string; title: string; description?: string; host?: string };
+}
+
export interface Message {
id: string;
channelId: string;
diff --git a/apps/storybook/src/stories/voice-message.stories.tsx b/apps/storybook/src/stories/voice-message.stories.tsx
index 39c5d8b3..740f8212 100644
--- a/apps/storybook/src/stories/voice-message.stories.tsx
+++ b/apps/storybook/src/stories/voice-message.stories.tsx
@@ -13,7 +13,7 @@ const meta = {
docs: {
description: {
component:
- "Voice-note attachment with a play button, stable waveform, duration, and optional transcript. The waveform is decorative by default and uses brand-subtle fills at rest.",
+ "Voice-note attachment with a play button, stable waveform, duration, and optional transcript. The transcript is hidden by default to keep the feed compact — a captions toggle appears on hover and expands the text inline. Pass `defaultTranscriptOpen` to start expanded.",
},
},
},
@@ -29,6 +29,22 @@ export const WithTranscript: Story = {
transcript:
"We sized the SLO at 99.5 so we've got room, and I'd rather trip the breaker less often than page someone.",
},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Hover the card to reveal the captions toggle (top-right of the waveform row), then click it to expand the transcript. Click again to collapse.",
+ },
+ },
+ },
+};
+
+export const TranscriptOpen: Story = {
+ args: {
+ transcript:
+ "Starts expanded via `defaultTranscriptOpen` — useful when the transcript is the primary content (e.g. in search results or accessibility-first contexts).",
+ defaultTranscriptOpen: true,
+ },
};
export const Playing: Story = {
@@ -40,6 +56,6 @@ export const Playing: Story = {
export const CustomWaveform: Story = {
args: {
- waveform: [4, 8, 14, 18, 22, 24, 22, 18, 14, 8, 4, 8, 14, 18, 22, 24, 22, 18, 14, 8],
+ waveform: [1, 1, 2, 3, 3, 4, 3, 3, 2, 1, 1, 1, 2, 3, 3, 4, 3, 3, 2, 1],
},
};
diff --git a/packages/ui-web/src/primitives/button.tsx b/packages/ui-web/src/primitives/button.tsx
index 54ada0aa..dab4eeeb 100644
--- a/packages/ui-web/src/primitives/button.tsx
+++ b/packages/ui-web/src/primitives/button.tsx
@@ -30,8 +30,13 @@ const buttonVariants = cva(
md: "h-10 rounded-xl px-4 py-2 font-semibold",
lg: "h-12 rounded-xl px-6 text-lg font-semibold shadow-sm hover:shadow-lg hover:shadow-accent/20",
inline: "h-auto px-0",
- icon: "size-10 p-0",
- "icon-sm": "size-6 p-0",
+ /* Icon sizes pin `rounded-md` (8px) — the base `rounded-lg`
+ (12px per tokens) would land at exactly half the `icon-sm`
+ width (24px) and render a perfect circle on hover. Controls
+ live at `--radius-md` per the design spec; only cards / panels
+ use the larger `rounded-lg` radius. */
+ icon: "size-10 rounded-md p-0",
+ "icon-sm": "size-6 rounded-md p-0",
},
},
defaultVariants: {
diff --git a/packages/ui-web/src/primitives/chat-message.tsx b/packages/ui-web/src/primitives/chat-message.tsx
index abadccc9..b75845e8 100644
--- a/packages/ui-web/src/primitives/chat-message.tsx
+++ b/packages/ui-web/src/primitives/chat-message.tsx
@@ -182,10 +182,15 @@ export const ChatMessage = React.forwardRef(
key={r.emoji}
data-reacted={r.reacted ? "" : undefined}
className={cn(
+ // Monochrome pill. Reacted state carries the same surface-2
+ // wash + border as the hover preview, so hovering an
+ // un-reacted pill visually foreshadows what clicking will
+ // do. Brand teal was over-weighting emoji reactions in the
+ // feed — they'd read louder than the message itself.
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
r.reacted
- ? "border-brand-subtle bg-brand-subtle text-brand-primary"
- : "border-border-subtle bg-surface-1 text-text-secondary hover:border-border-hover hover:bg-brand-subtle hover:text-brand-primary",
+ ? "border-border bg-surface-2 text-text-primary"
+ : "border-border-subtle bg-surface-1 text-text-secondary hover:border-border hover:bg-surface-2 hover:text-text-primary",
)}
>
{r.emoji}
diff --git a/packages/ui-web/src/primitives/dialog.tsx b/packages/ui-web/src/primitives/dialog.tsx
index a543adca..aadff9fa 100644
--- a/packages/ui-web/src/primitives/dialog.tsx
+++ b/packages/ui-web/src/primitives/dialog.tsx
@@ -151,7 +151,7 @@ const DialogTitle = React.forwardRef<
));
@@ -168,7 +168,7 @@ const DialogDescription = React.forwardRef<
));
diff --git a/packages/ui-web/src/primitives/file-attachment.tsx b/packages/ui-web/src/primitives/file-attachment.tsx
index 29259dcc..8ab44fca 100644
--- a/packages/ui-web/src/primitives/file-attachment.tsx
+++ b/packages/ui-web/src/primitives/file-attachment.tsx
@@ -76,7 +76,7 @@ export const FileAttachment = React.forwardRef(
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
archived
? "border-border-subtle bg-surface-1 opacity-70 hover:opacity-100"
- : "border-border bg-surface-1 hover:border-border-hover hover:shadow-sm",
+ : [
+ // Topics are one visual tier above chat messages. A light
+ // brand-tinted border (no fill) signals "persistent thread
+ // worth tracking" without competing with the status pill
+ // inside — the pill (active / needs-review / blocked / done)
+ // is what carries semantic color, and flooding every card
+ // with a brand wash dilutes that signal. Hover deepens the
+ // border and lifts the card via shadow + a neutral surface-1
+ // fill so the affordance is unmistakable.
+ "border-brand-primary/20 bg-surface-1",
+ "hover:border-brand-primary/40 hover:shadow-sm",
+ ],
className,
)}
{...rest}
diff --git a/packages/ui-web/src/primitives/voice-message.test.tsx b/packages/ui-web/src/primitives/voice-message.test.tsx
index 12433f46..8984138e 100644
--- a/packages/ui-web/src/primitives/voice-message.test.tsx
+++ b/packages/ui-web/src/primitives/voice-message.test.tsx
@@ -3,11 +3,36 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { VoiceMessage } from "./voice-message";
describe("VoiceMessage", () => {
- it("renders duration and transcript", () => {
+ it("renders duration but hides transcript by default", () => {
render();
expect(screen.getByText("0:24")).toBeInTheDocument();
+ expect(screen.queryByText(/Hello from the team/)).not.toBeInTheDocument();
+ });
+
+ it("reveals transcript when the toggle is clicked", () => {
+ render();
+
+ const toggle = screen.getByRole("button", { name: /show transcript/i });
+ fireEvent.click(toggle);
+
+ expect(screen.getByText(/Hello from the team/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /hide transcript/i })).toBeInTheDocument();
+ });
+
+ it("starts expanded when defaultTranscriptOpen is true", () => {
+ render(
+ ,
+ );
+
expect(screen.getByText(/Hello from the team/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /hide transcript/i })).toBeInTheDocument();
+ });
+
+ it("omits the transcript toggle entirely when no transcript is provided", () => {
+ render();
+
+ expect(screen.queryByRole("button", { name: /transcript/i })).not.toBeInTheDocument();
});
it("exposes an accessible play button and forwards clicks", () => {
diff --git a/packages/ui-web/src/primitives/voice-message.tsx b/packages/ui-web/src/primitives/voice-message.tsx
index c34287b8..f1b50faf 100644
--- a/packages/ui-web/src/primitives/voice-message.tsx
+++ b/packages/ui-web/src/primitives/voice-message.tsx
@@ -1,19 +1,30 @@
-import { Play, Volume2 } from "lucide-react";
+import { ChevronDown, Play, Volume2 } from "lucide-react";
import * as React from "react";
import { cn } from "../lib/cn";
/**
- * A synthetic waveform used when the caller does not supply one; stable heights
- * so the visual rhythm is consistent across renders.
+ * A synthetic waveform used when the caller does not supply one. The waveform
+ * is intentionally decorative — stable heights, low visual weight — so it
+ * reads as "there is audio here" without competing with the message text.
+ * Values are tuned for a compact `h-3` (12px) bar container; keep custom
+ * waveforms in roughly the 1–4 range (final render = value + 2 so max ≈ 6px).
*/
-const DEFAULT_WAVEFORM = [3, 6, 10, 14, 8, 12, 18, 22, 16, 10, 14, 20, 18, 8, 12, 6, 14, 10, 4, 8];
+const DEFAULT_WAVEFORM = [1, 1, 2, 3, 2, 2, 3, 4, 3, 2, 3, 4, 3, 2, 2, 1, 3, 2, 1, 2];
export interface VoiceMessageProps extends React.HTMLAttributes {
/** Formatted duration label, e.g. "0:24". */
duration: string;
- /** Optional transcript shown below the waveform. */
+ /**
+ * Optional transcript. Hidden by default; a "Show transcript" toggle appears
+ * on hover and reveals the text. Pass `defaultTranscriptOpen` to start
+ * expanded.
+ */
transcript?: React.ReactNode;
+ /** If true, the transcript is expanded on first render (default: false). */
+ defaultTranscriptOpen?: boolean;
+ /** Accessible label for the transcript toggle (localisable). */
+ transcriptToggleLabel?: string;
/** Waveform bar heights in pixels; a stable default is used when omitted. */
waveform?: number[];
/** Called when the play button is pressed. */
@@ -34,6 +45,8 @@ export const VoiceMessage = React.forwardRef(
className,
duration,
transcript,
+ defaultTranscriptOpen = false,
+ transcriptToggleLabel,
waveform = DEFAULT_WAVEFORM,
onPlay,
state = "idle",
@@ -41,54 +54,82 @@ export const VoiceMessage = React.forwardRef(
},
ref,
) => {
+ const [transcriptOpen, setTranscriptOpen] = React.useState(defaultTranscriptOpen);
+ const hasTranscript = transcript !== undefined && transcript !== null && transcript !== "";
+ const toggleLabel =
+ transcriptToggleLabel ?? (transcriptOpen ? "Hide transcript" : "Show transcript");
+
return (