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 )}
{user.email} @@ -183,8 +213,7 @@ export function AgentsSidebar(): React.ReactElement { {filteredAgents.length > 0 && (
- - {t("team.agents")} + Agents {filteredAgents.length} @@ -203,13 +232,17 @@ export function AgentsSidebar(): React.ReactElement { : "text-nav-muted hover:bg-nav-hover hover:text-nav-fg", )} > - +
{agent.name}
{agent.description} @@ -221,7 +254,7 @@ export function AgentsSidebar(): React.ReactElement { )} {filteredUsers.length === 0 && filteredAgents.length === 0 && ( -
{t("team.noResults")}
+
No matches
)}
diff --git a/apps/slark/src/renderer/src/components/agents/AgentsView.tsx b/apps/slark/src/renderer/src/components/agents/AgentsView.tsx index c1b38f5b..8e4c7085 100644 --- a/apps/slark/src/renderer/src/components/agents/AgentsView.tsx +++ b/apps/slark/src/renderer/src/components/agents/AgentsView.tsx @@ -47,7 +47,7 @@ export function AgentsView(): React.ReactElement { return (
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.name}

- {user.role === "owner" && ( - - {t("team.role.owner")} - - )} +
+
+

{user.name}

+ {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. */} +
+ + {statusLabel} +
-
{statusLabel}
+ {user.id !== "u-1" && ( + + )}
- {user.id !== "u-1" && ( - - )} -
-
-
-

- {t("team.userProfile")} -

-
-
- - Email - {user.email} -
-
- - - {user.role === "owner" ? t("team.role.owner") : t("team.role.member")} - - {user.role} +
+
+

+ Profile +

+
+
+ + Email + {user.email} +
+
+ + Role + {user.role} +
-
-
+ -
-

- {t("team.userChannels")} -

- {userChannels.length === 0 ? ( -
- — -
- ) : ( -
- {userChannels.map((c) => ( - - ))} -
- )} -
+
+

+ Channels +

+ {userChannels.length === 0 ? ( +
+ — +
+ ) : ( +
+ {userChannels.map((c) => ( + + ))} +
+ )} +
+
); diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 0414b9d7..8d44468a 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx @@ -125,7 +125,7 @@ export function ChatSidebar(): React.ReactElement { size="inline" onClick={() => handleSelect(c.id)} className={cn( - "flex items-center gap-2 w-full pl-3 pr-2 py-[5px] text-[13px] transition-colors", + "flex items-center gap-2.5 w-full rounded-md pl-2 pr-2 py-2 text-[13px] transition-colors", isActive ? "bg-nav-active text-nav-active-fg font-semibold" : unread @@ -134,17 +134,17 @@ export function ChatSidebar(): React.ReactElement { )} > {isChannel ? ( - + ) : resolved ? ( - + ) : ( - + )} {label} {unread ? ( @@ -173,59 +173,50 @@ export function ChatSidebar(): React.ReactElement { return (
-
+
setSearch(e.target.value)} placeholder={t("chat.search")} leadingIcon={} - className="h-8 border-transparent bg-nav-input text-nav-fg shadow-none focus-within:border-transparent focus-within:ring-1 focus-within:ring-nav-ring" + className="h-8 border-border-subtle bg-nav-input text-nav-fg shadow-none focus-within:border-transparent focus-within:ring-1 focus-within:ring-nav-ring" inputClassName="text-[13px] placeholder:text-nav-muted" />
-
+
{pinnedChannels.length > 0 && (
-
- - {t("chat.pinned")} +
+ + Pinned
- {pinnedChannels.map((c) => renderRow(c))} +
{pinnedChannels.map((c) => renderRow(c))}
)}
-
- {t("chat.channels")} +
+ Channels
- {channelList.map((c) => renderRow(c, { showDelete: true }))} - +
+ {channelList.map((c) => renderRow(c, { showDelete: true }))} +
@@ -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. + */} +
+ {headerRow} + + + + + Messages + + + + Files + + + + Artifacts + + +
+ + + {/* + 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." + /> +
+
+ + ) : ( + <> + + {headerRow} + + + + + )} - {channel.type === "channel" && ( - - )} - - - {channel.type === "channel" && ( void; onExpand?: (block: ContentBlock) => void; + onTopicOpen?: (block: Extract) => void; } function formatFileSize(bytes: number): string { @@ -88,8 +104,8 @@ function ImageBlock({ @@ -157,9 +173,14 @@ function VoiceBlock({ function TopicBlock({ block, -}: { block: Extract }): React.ReactElement { + onOpen, +}: { + block: Extract; + onOpen?: () => void; +}): React.ReactElement { return ( void; +}): React.ReactElement { + const interactive = typeof onClick === "function"; + return ( + + ); +} function CodeBlock({ block, - isMe, onExpand, }: { block: Extract; - isMe: boolean; onExpand?: () => void; }): React.ReactElement { - const [copied, setCopied] = useState(false); - const lines = block.code.split("\n"); - const isTruncated = lines.length > CODE_PREVIEW_LINES; - const previewCode = isTruncated ? lines.slice(0, CODE_PREVIEW_LINES).join("\n") : block.code; - - const handleCopy = (e: React.MouseEvent): void => { - e.stopPropagation(); - navigator.clipboard.writeText(block.code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; + const lineCount = block.code.split("\n").length; + const primary = block.filename ?? `${block.language ?? "code"} snippet`; + const meta = `${lineCount} line${lineCount === 1 ? "" : "s"}`; return ( -
} + primary={primary} + meta={meta} onClick={onExpand} - role={onExpand ? "button" : undefined} - tabIndex={onExpand ? 0 : undefined} - onKeyDown={ - onExpand - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onExpand(); - } - } - : undefined - } - > -
-
- {block.filename && ( - {block.filename} - )} - {block.language && !block.filename && ( - {block.language} - )} - {block.language && block.filename && ( - {block.language} - )} -
-
- - {isTruncated && ( - {lines.length} lines - )} -
-
-
-        {previewCode}
-      
- {isTruncated && ( -
- Click to see all {lines.length} lines -
- )} -
+ /> ); } @@ -273,28 +272,28 @@ function ActionCard({ return (
- {block.status === "running" && } - {block.status === "success" && } - {block.status === "failed" && } + {block.status === "running" && } + {block.status === "success" && } + {block.status === "failed" && }
-

{block.title}

+

{block.title}

{block.description && ( -

+

{block.description}

)} {block.tool && ( -
- - {block.tool} +
+ + {block.tool}
)}
@@ -308,39 +307,37 @@ function ToolResultBlock({ const [expanded, setExpanded] = useState(false); return ( -
- + {expanded ? ( + + ) : ( + + )} + {expanded && ( -
+
{block.input && (
-

+

Input

-
+              
                 {block.input}
               
@@ -348,13 +345,13 @@ function ToolResultBlock({
-

+

Output

-
+            
               {block.output}
             
@@ -364,75 +361,22 @@ function ToolResultBlock({ ); } -const DIFF_PREVIEW_LINES = 10; - function DiffBlock({ block, onExpand, }: { block: Extract; onExpand?: () => void }): React.ReactElement { - const lines = block.content.split("\n"); - const isTruncated = lines.length > DIFF_PREVIEW_LINES; - const previewLines = isTruncated ? lines.slice(0, DIFF_PREVIEW_LINES) : lines; - const previewEntries = createIndexedItems(previewLines, (line) => line); - return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onExpand(); - } - } - : undefined + } + primary={block.filename} + meta={ + + +{block.additions} + -{block.deletions} + } - > -
-
- - {block.filename} -
-
- +{block.additions} - -{block.deletions} -
-
-
- {previewEntries.map(({ key, value: line }) => { - const isAdd = line.startsWith("+"); - const isDel = line.startsWith("-"); - const isHunk = line.startsWith("@@"); - - return ( -
- {line || "\u00a0"} -
- ); - })} -
- {isTruncated && ( -
- Click to see all {lines.length} lines -
- )} -
+ onClick={onExpand} + /> ); } @@ -446,60 +390,69 @@ function ApprovalBlock({ return (
-

{block.title}

+

{block.title}

{block.description && ( -

+

{block.description}

)}
-
+
{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. + */
- +
)} {block.status === "approved" && ( -
- +
+ Approved
)} {block.status === "rejected" && ( -
- +
+ Rejected
)} @@ -511,73 +464,215 @@ function ApprovalBlock({ function ProgressBlock({ block, }: { block: Extract }): React.ReactElement { - const pct = Math.round((block.current / block.total) * 100); - const isDone = pct >= 100; + const isDone = block.current >= block.total; const indexedSteps = block.steps ? createIndexedItems(block.steps, (step) => `${step.label}-${step.status}`) : []; return ( -
-
-

{block.title}

+
+ {/* + * 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.status === "active" && ( +
    ); } +/** + * 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 ? ( +
    + + {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 ( -
    -
    - {step === "members" ? ( - - ) : null} -
    - - {step === "details" ? t("createChannel.title") : t("createChannel.addMembers")} - - {subtitle} -
    -
    -
    -
    -
    -
    -
    + Create channel + + Channels are where your team collaborates on a topic or project. + - {step === "details" ? ( -
    - - - setName(event.target.value)} - onKeyDown={handleDetailsKeyDown} - placeholder={t("createChannel.namePlaceholder")} - leadingIcon={} - /> - - - - - {t("createChannel.descLabel")}{" "} - - {t("createChannel.optional")} - - - } - > - - setDescription(event.target.value)} - onKeyDown={handleDetailsKeyDown} - placeholder={t("createChannel.descPlaceholder")} - /> - - -
    - ) : ( -
    - - - setQuery(event.target.value)} - placeholder={t("createChannel.searchPlaceholder")} - leadingIcon={} - /> - - - -
    - {filteredUsers.length === 0 && filteredAgents.length === 0 ? ( - } - className="border-border-subtle" - /> - ) : ( - <> - - {filteredUsers.map((user) => { - const selected = selectedUserIds.indexOf(user.id) !== -1; - return ( - toggleUser(user.id)} - className="px-3 py-2" - > - - - - - {user.name.slice(0, 2).toUpperCase()} - - - - -
    - {user.name} -
    -
    {user.email}
    -
    - - {selected ? ( - - ) : null} - -
    - ); - })} -
    - - - {filteredAgents.map((agent) => { - const selected = selectedAgentIds.indexOf(agent.id) !== -1; - return ( - toggleAgent(agent.id)} - className="px-3 py-2" - > - - {agent.avatar ? ( - {agent.name} - ) : ( -
    - -
    - )} -
    - -
    - - {agent.name} - - - {t("createChannel.agentBadge")} - -
    - {agent.description ? ( -
    - {agent.description} -
    - ) : null} -
    - - {selected ? ( - - ) : null} - -
    - ); - })} -
    - - )} -
    -
    - )} +
    + + + setName(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g. design-review" + leadingIcon={} + /> + + + + + Description (optional) + + } + > + + setDescription(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="What's this channel about?" + /> + + +
    + - {step === "details" ? ( - - ) : ( - - )}
    ); } - -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 ( + + + +
    +
    + + {topic.title} + + {status && ( + + {status.label} + + )} +
    +

    + Started by {topic.author} ·{" "} + {topic.lastActivity} + {topic.assignee ? ( + <> + {" · assigned to "} + + {topic.assignee.name} + + + ) : null} +

    +
    + +
    + + +
    + + + + Thread + + + + Files + + + + Members + + + + Pinned + + +
    + + + + + + + {hasAnyFiles ? ( +
    + {sharedImages.length > 0 && ( +
    +

    + Images ({sharedImages.length}) +

    +
    + {sharedImages.map((m) => ( + + ))} +
    +
    + )} + {sharedLinks.length > 0 && ( +
    +

    + Links ({sharedLinks.length}) +

    +
      + {sharedLinks.map((m) => ( +
    • + +
    • + ))} +
    +
    + )} +
    + ) : ( +
    + } + 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." + /> + )} +
    + ); + } + + return ( +
    + {topic.preview ? ( +
    +

    + Opening context +

    +

    {topic.preview}

    +
    + ) : null} + +
      + {thread.map((msg) => ( +
    • + +
    • + ))} +
    +
    + ); +} + +function ThreadReply({ message }: { message: TopicThreadMessage }): React.ReactElement { + const { author, initials, isAgent, accent, createdAtLabel, text, image, link } = message; + + return ( +
    + +
    +
    + + {author} + + {isAgent && ( + + Agent + + )} + + {createdAtLabel} + +
    + {text ? ( +

    {text}

    + ) : null} + {image ? ( +
    + +
    + ) : null} + {link ? ( +
    + +
    + ) : null} +
    +
    + ); +} + +function LinkPreviewCard({ + link, +}: { + link: NonNullable; +}): React.ReactElement { + return ( + + +
    +

    {link.title}

    + {link.description ? ( +

    + {link.description} +

    + ) : null} + {link.host ? ( +

    {link.host}

    + ) : null} +
    +
    + ); +} diff --git a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx index 2cc95ef3..b383f402 100644 --- a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx +++ b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx @@ -3,7 +3,6 @@ import { ActivityBarContent, ActivityBarFooter, ActivityBarHeader, - ActivityBarIndicator, ActivityBarItem, Avatar, AvatarFallback, @@ -44,7 +43,7 @@ export function ActivityBar(): React.ReactElement { const [menuOpen, setMenuOpen] = useState(false); return ( - + @@ -97,8 +96,12 @@ export function ActivityBar(): React.ReactElement { ); })} - - + {/* No separator here — `Add workspace` is a sibling affordance + to the workspace rows above (same row treatment, just with a + `+` tile instead of an avatar), so splitting them with a + divider implies a semantic break that doesn't exist. The + separator only appears before `Sign out` to fence off the + destructive action. */} = 5} title={workspaces.length >= 5 ? "Workspace limit reached (5 max)" : undefined} @@ -108,9 +111,7 @@ export function ActivityBar(): React.ReactElement {
    - - {t("workspace.addWorkspace")} - + Add workspace {workspaces.length >= 5 ? ( {workspaces.length}/5 @@ -120,15 +121,21 @@ export function ActivityBar(): React.ReactElement { + {/* Sign-out follows the destructive-intent hover rule in + `AGENTS.md`: resting state stays neutral dark text (so the + row doesn't shout at users every time they open the menu), + and destructive red only appears on hover / keyboard focus + to signal the irreversible action. The `LogOut` glyph uses + `currentColor` and inherits the same transition. */} { reset(); navigate("/"); }} - className="gap-2.5 rounded-lg px-3 py-2 text-destructive focus:text-destructive" + className="gap-2.5 rounded-lg px-3 py-2 text-text-primary hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive" > - {t("workspace.logOut")} + Sign out of Nexu @@ -146,9 +153,6 @@ export function ActivityBar(): React.ReactElement { className="no-drag size-10 rounded-xl text-nav-muted hover:bg-nav-hover hover:text-nav-fg data-[active=true]:bg-nav-active data-[active=true]:text-nav-active-fg" title={t(labelKey)} > - {isActive ? ( - - ) : null} ); @@ -162,9 +166,6 @@ export function ActivityBar(): React.ReactElement { className="no-drag size-10 rounded-xl text-nav-muted hover:bg-nav-hover hover:text-nav-fg data-[active=true]:bg-nav-active data-[active=true]:text-nav-active-fg" title={t("section.settings")} > - {location.pathname.startsWith("/settings") ? ( - - ) : null} diff --git a/apps/slark/src/renderer/src/components/layout/AppLayout.tsx b/apps/slark/src/renderer/src/components/layout/AppLayout.tsx index 9d11a20f..2ef2a9f1 100644 --- a/apps/slark/src/renderer/src/components/layout/AppLayout.tsx +++ b/apps/slark/src/renderer/src/components/layout/AppLayout.tsx @@ -4,10 +4,10 @@ import { Sidebar } from "./Sidebar"; export function AppLayout(): React.ReactElement { return ( -
    +
    -
    +
    diff --git a/apps/slark/src/renderer/src/components/layout/DevPanel.tsx b/apps/slark/src/renderer/src/components/layout/DevPanel.tsx index 86859f6c..a17d5279 100644 --- a/apps/slark/src/renderer/src/components/layout/DevPanel.tsx +++ b/apps/slark/src/renderer/src/components/layout/DevPanel.tsx @@ -227,7 +227,7 @@ export function DevPanel(): React.ReactElement { "flex items-center gap-2 w-full px-2.5 py-1.5 rounded-md text-xs transition-colors", currentState === id ? "bg-nexu-primary/15 text-nexu-primary font-medium" - : "text-muted-foreground hover:bg-accent hover:text-foreground", + : "text-muted-foreground hover:bg-surface-2 hover:text-foreground", )} > @@ -259,7 +259,7 @@ export function DevPanel(): React.ReactElement { "flex items-center gap-2 w-full px-2.5 py-1.5 rounded-md text-xs transition-colors", currentUserId === user.id ? "bg-accent text-foreground font-medium" - : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + : "text-muted-foreground hover:bg-surface-2 hover:text-foreground", )} > @@ -301,7 +301,7 @@ export function DevPanel(): React.ReactElement { className={cn( "flex items-center gap-2 w-full px-2.5 py-1.5 rounded-md text-xs transition-colors", hasRuntimes - ? "text-muted-foreground hover:bg-accent hover:text-foreground" + ? "text-muted-foreground hover:bg-surface-2 hover:text-foreground" : "bg-nexu-primary/15 text-nexu-primary font-medium", )} > @@ -317,7 +317,7 @@ export function DevPanel(): React.ReactElement { "flex items-center gap-2 w-full px-2.5 py-1.5 mt-1 rounded-md text-xs transition-colors", devSimulateNoDetection ? "bg-nexu-primary/15 text-nexu-primary font-medium" - : "text-muted-foreground hover:bg-accent hover:text-foreground", + : "text-muted-foreground hover:bg-surface-2 hover:text-foreground", )} > {devSimulateNoDetection ? ( @@ -346,7 +346,7 @@ export function DevPanel(): React.ReactElement { "flex items-center gap-2 w-full px-2.5 py-1.5 rounded-md text-xs transition-colors", workspacesAtLimit ? "bg-nexu-primary/15 text-nexu-primary font-medium cursor-not-allowed" - : "text-muted-foreground hover:bg-accent hover:text-foreground", + : "text-muted-foreground hover:bg-surface-2 hover:text-foreground", )} > diff --git a/apps/slark/src/renderer/src/components/layout/Sidebar.tsx b/apps/slark/src/renderer/src/components/layout/Sidebar.tsx index a20947c4..6928d9c3 100644 --- a/apps/slark/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/slark/src/renderer/src/components/layout/Sidebar.tsx @@ -144,7 +144,11 @@ export function Sidebar(): React.ReactElement {
    - ) : 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(currentSection.labelKey)}
    diff --git a/apps/slark/src/renderer/src/components/onboarding/ConnectRuntimeStep.tsx b/apps/slark/src/renderer/src/components/onboarding/ConnectRuntimeStep.tsx index 56e9c88a..ad7a5e12 100644 --- a/apps/slark/src/renderer/src/components/onboarding/ConnectRuntimeStep.tsx +++ b/apps/slark/src/renderer/src/components/onboarding/ConnectRuntimeStep.tsx @@ -270,7 +270,7 @@ export function ConnectRuntimeStep(): React.ReactElement { href={g.docsUrl} target="_blank" rel="noreferrer" - className="group flex flex-col gap-1.5 p-3 rounded-xl border border-border hover:border-muted-foreground/50 hover:bg-accent/40 transition-all text-left" + className="group flex flex-col gap-1.5 p-3 rounded-xl border border-border hover:border-muted-foreground/50 hover:bg-surface-2 transition-all text-left" >
    = { - "claude-code": Terminal, - cursor: MousePointer, - opencode: Code, - hermes: Cpu, - codex: Box, - "gemini-cli": Sparkles, -}; - type Tab = "mine" | "all"; export function RuntimesSidebar(): React.ReactElement { - const t = useT(); const { runtimes, addRuntime, selectRuntime, selectedRuntimeId, devSimulateNoDetection } = useRuntimesStore(); const currentUserId = useWorkspaceStore((s) => s.currentUserId); @@ -115,9 +104,17 @@ export function RuntimesSidebar(): React.ReactElement { return (
    -
    - - {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. */} +
    + + Runtimes + + + {onlineCount}/{runtimes.length} online
    @@ -134,7 +131,7 @@ export function RuntimesSidebar(): React.ReactElement { : "text-nav-muted hover:text-nav-fg", )} > - {t("runtimes.mine")} + Mine
    {filtered.map((rt) => { - const Icon = typeIcons[rt.type]; const ownerUser = tab === "all" ? mockUsers.find((u) => u.id === rt.ownerId) : null; return ( - )} + + Detected on this device + +
    {!scanning && detectedNotAdded.length === 0 ? ( -

    {t("runtimes.noNew")}

    +

    No new runtimes found.

    ) : ( detectedNotAdded.map((d) => { - const Icon = typeIcons[d.type]; return (
    - +
    {d.name}
    -
    +
    v{d.version} · {d.path}
    + {/* `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. */}
    ); 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 {
    -

    {t("runtimes.selectRuntime")}

    +

    Select a runtime

    ); } - const Icon = typeIcons[rt.type]; - const ver = rt.version ? `v${rt.version}` : t("runtimes.notInstalled"); + const ver = rt.version ? `v${rt.version}` : "Not installed"; const hasUpdate = rt.type === "claude-code" && rt.version; const data = getData(rt); const usage = data.usage[period]; @@ -365,9 +363,13 @@ 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 {
    - - - - + + + + {hasUpdate ? (
    {ver} @@ -433,9 +442,9 @@ export function RuntimesView(): ReactElement { type="button" variant="outline" size="sm" - className="ml-1 h-6 px-2 text-[11px] font-medium hover:bg-accent transition-colors" + className="ml-1 h-6 px-2 text-[11px] font-medium hover:bg-surface-2 transition-colors" > - {t("runtimes.update")} + Update
    ) : ( @@ -447,21 +456,21 @@ export function RuntimesView(): ReactElement { type="button" variant="outline" size="sm" - className="h-8 px-3 text-xs font-medium flex items-center gap-1.5 hover:bg-accent transition-colors" + className="h-8 px-3 text-xs font-medium flex items-center gap-1.5 hover:bg-surface-2 transition-colors" > - {t("runtimes.testConnection")} + Test connection
    -

    {t("runtimes.linkedAgents")}

    +

    Linked agents

    {(() => { const linked = agents.filter((a) => a.runtimeId === rt.id); if (linked.length === 0) { return (

    - {t("runtimes.noAgentsUsing")} + No agents use this runtime

    ); } @@ -477,9 +486,9 @@ export function RuntimesView(): ReactElement { navigate("/agents"); setTimeout(() => useAgentsStore.getState().selectAgent(agent.id), 50); }} - className="flex items-center gap-3 w-full px-3 py-2 rounded-lg hover:bg-accent transition-colors text-left" + className="flex items-center gap-3 w-full px-3 py-2 rounded-lg hover:bg-surface-2 transition-colors text-left" > -
    +
    {agent.avatar ? ( ) : ( @@ -509,7 +518,7 @@ export function RuntimesView(): ReactElement {
    -

    {t("runtimes.tokenUsage")}

    +

    Token usage

    {usagePeriods.map((p) => (
    @@ -577,14 +584,12 @@ export function RuntimesView(): ReactElement {
    ) : ( -

    - {t("runtimes.noModelData")} -

    +

    No model usage data

    )}
    -

    {t("runtimes.activity")}

    +

    Activity

    {weekLabels.map((w) => ( @@ -622,22 +627,22 @@ export function RuntimesView(): ReactElement {
    - {t("runtimes.less")} + Less {heatmapLevels.map((cls) => (
    ))} - {t("runtimes.more")} + More
    - {t("runtimes.created")} + Created

    {data.createdAt}

    - {t("runtimes.updated")} + Updated

    {data.updatedAt}

    @@ -708,58 +713,126 @@ const installGuides: InstallGuide[] = [ }, ]; +function InstallCommand({ + command, + className, +}: { command: string; className?: string }): ReactElement { + const [copied, setCopied] = useState(false); + + const handleCopy = (e: React.MouseEvent): void => { + /* The card routes bare clicks to a docs overlay `` behind the + content. Prevent default to stop that navigation and stop + propagation so the parent's hover / link handling leaves this + button alone. */ + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard + .writeText(command) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }) + .catch(() => { + /* Clipboard permission denied or unavailable — silently ignore; + the user can still read and manually copy the command text. */ + }); + }; + + return ( +
    + + {command} + + +
    + ); +} + function EmptyRuntimesGuide(): ReactElement { - const t = useT(); return (
    -
    +
    -

    {t("runtimes.emptyTitle")}

    -

    {t("runtimes.emptyDesc")}

    +

    No runtimes installed

    +

    + Agents need a runtime to execute tasks. Install one of the supported runtimes below, + then return here to connect it. +

    {installGuides.map((g) => { - const Icon = typeIcons[g.type as Runtime["type"]] ?? Terminal; return ( - `: the + install command needs its own click-to-copy button, and + nesting ` +
    + {waveform.map((height, index) => ( + + ))} +
    + + {duration} + +
    + {hasTranscript && transcriptOpen ? ( +

    + + {transcript} +

    + ) : null} +
    + {hasTranscript ? ( -
    - {waveform.map((height, index) => ( - - ))} -
    - - {duration} - -
    - {transcript ? ( -

    - - {transcript} -

    ) : null}
    );