From b8d034ae5aee2b8c9026ff508b2c1c81a4e78f20 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 18:49:39 +0800 Subject: [PATCH 01/42] fix(slark): replace brand-primary nav fills with brand-subtle wash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slark app maps --slark-color-nav-active to --color-brand-primary, which means selected channels, active activity-bar icons, runtime tabs, agent rows, and more all render with a saturated teal background. This over-uses brand-primary — AGENTS.md reserves that color for links, focus rings, accented badges, and brand emphasis, not large surface fills. Switch to the brand-subtle wash pattern (same pair used by Plus tier badges: pale brand background + brand-primary text): --slark-color-nav-active → var(--color-brand-subtle) --slark-color-nav-active-fg → var(--color-brand-primary) --slark-color-nav-active-soft / -muted rebased on brand-primary All 11 call sites across ChatSidebar, ActivityBar, Sidebar, RuntimesSidebar, and AgentsSidebar pick this up automatically via the tokens. The ActivityBar's 3px selection indicator (bg-nav-active-fg) becomes brand-primary on a brand-subtle row — clear brand accent on a soft wash. Also refactor the one call site where the nav tokens were being abused as a primary-button style: the "Add" button for detected runtimes now uses From 35b77cb2e9bdacc3bdaeaaa1373adf6467b9959f Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 18:57:04 +0800 Subject: [PATCH 02/42] style(slark): use neutral surface-3 + text-heading for selected nav rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on review feedback: the brand-subtle wash still read as "blue selection" at a glance. Switch to the restrained Slack / Cursor pattern — neutral surface-3 fill + text-heading (near-black) + font-semibold — so selection is communicated by weight and text contrast instead of color. Zero brand tint in the selected row. --slark-color-nav-active → var(--color-surface-3) --slark-color-nav-active-fg → var(--color-text-heading) --slark-color-nav-active-soft → color-mix(text-heading 18%, transparent) --slark-color-nav-active-muted → color-mix(text-heading 78%, transparent) Also drop the 3px ActivityBarIndicator on active items: with a solid accent fill gone, the indicator was redundant decoration. Primary-action buttons (unread badges in ChatSidebar, brand-colored CTAs like the runtime Add button migrated in the previous commit) are unaffected — they keep using --color-brand-primary / default Button variant per AGENTS.md. Made-with: Cursor --- apps/slark/src/renderer/src/app/globals.css | 18 ++++++++++-------- .../src/components/layout/ActivityBar.tsx | 7 ------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/slark/src/renderer/src/app/globals.css b/apps/slark/src/renderer/src/app/globals.css index af59153a..307713e2 100644 --- a/apps/slark/src/renderer/src/app/globals.css +++ b/apps/slark/src/renderer/src/app/globals.css @@ -17,15 +17,17 @@ --slark-color-nav-surface: var(--color-surface-2); --slark-color-nav-hover: var(--color-surface-2); /* - * Selected navigation row uses the brand-subtle wash pattern from AGENTS.md - * (same pair as Plus tier badge): pale brand background + brand-primary text. - * Avoids using --color-brand-primary as a large fill, which the design system - * reserves for links, focus rings, accented badges, and brand emphasis. + * 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-brand-subtle); - --slark-color-nav-active-fg: var(--color-brand-primary); - --slark-color-nav-active-soft: color-mix(in srgb, var(--color-brand-primary) 18%, transparent); - --slark-color-nav-active-muted: color-mix(in srgb, var(--color-brand-primary) 78%, transparent); + --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); diff --git a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx index 2cc95ef3..8c892248 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, @@ -146,9 +145,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 +158,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} From 1fdc1fa498af4cc39ece2f458492a51b4413e42f Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:03:46 +0800 Subject: [PATCH 03/42] docs(agents): add compact nav list density + selection background rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codifies the lessons from the slark nav color + density fixes into AGENTS.md so future work (AI agent or human) picks the right tokens on day one. Rules added under "Layout conventions": - Compact nav list density: px-2 container, 0 row gap, rounded-md, pl-2 pr-2 py-[3px] rows (~24px height), px-2 pt-3 pb-1 section headers — matches Slack / Cursor / Discord. - Selection row background: neutral surface-3 + text-heading + font-semibold; never fill persistent selection with brand/accent/ semantic tokens. Includes guidance for app-level CSS vars and an explicit exception clause for transient "spotlight" states. Slark fixes applying the new rules: - ChatSidebar: container px-2, rows rounded-md with no inter-row gap, unified "Add channels" affordance with list rows, section header aligned with list padding. - ChatView: use bg-surface-1 (pure white / --card) for the chat content panel instead of bg-background (surface-0 = page canvas, slight gray). Chat is a content panel, not the page shell. Made-with: Cursor --- AGENTS.md | 9 +++++++++ .../src/renderer/src/components/chat/ChatSidebar.tsx | 10 +++++----- .../src/renderer/src/components/chat/ChatView.tsx | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd8cd8b9..4a5bd295 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,15 @@ - **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: **0** — do not use `space-y-1` / `gap-1` between rows; the rounded selected/hover background already provides visual separation. Extra gap breaks the rows into disconnected pills. + - Row radius: `rounded-md` (8px) — `rounded-lg` (12px) looks pill-like inside narrow (≤240px) sidebars. + - Row padding: `pl-2 pr-2 py-[3px]` with 13px text + 16px icon yields a ~24px row height, which matches Slack / Cursor / Discord density. + - Section header: `px-2 pt-3 pb-1` — aligns with the row container padding; `pt-3` (12px) gives breathing room between sections. + - 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. - **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). diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 0414b9d7..5fd3c3d6 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 w-full rounded-md pl-2 pr-2 py-[3px] text-[13px] transition-colors", isActive ? "bg-nav-active text-nav-active-fg font-semibold" : unread @@ -184,10 +184,10 @@ export function ChatSidebar(): React.ReactElement { /> -
+
{pinnedChannels.length > 0 && (
-
+
{t("chat.pinned")}
@@ -196,7 +196,7 @@ export function ChatSidebar(): React.ReactElement { )}
-
+
{t("chat.channels")} diff --git a/apps/slark/src/renderer/src/components/chat/ChatView.tsx b/apps/slark/src/renderer/src/components/chat/ChatView.tsx index 28a3fabc..57d7f26d 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatView.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatView.tsx @@ -111,7 +111,7 @@ export function ChatView(): React.ReactElement { const otherResolved = otherMember ? resolveRef(otherMember) : undefined; return ( -
+
From a5044a8deda28213d63744da476a21dacfd5e4c9 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:12:08 +0800 Subject: [PATCH 05/42] refactor(slark): remove redundant "Add channels" row in ChatSidebar The section header already has a "+" icon button that opens the create-channel dialog. Having a second "+ Add channels" row at the bottom of the list is redundant affordance and adds visual noise in the compact nav density. - Remove the bottom "Add channels" Button row from ChatSidebar - Drop now-unused `chat.addChannels` i18n key (en-only, no zh parity) - Section header "+" button remains the single entry point for creating a channel, which is the Slack / Cursor convention Made-with: Cursor --- .../src/renderer/src/components/chat/ChatSidebar.tsx | 11 ----------- apps/slark/src/renderer/src/i18n/index.ts | 1 - 2 files changed, 12 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 0c4d781f..038a2ff1 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx @@ -215,17 +215,6 @@ export function ChatSidebar(): React.ReactElement {
{channelList.map((c) => renderRow(c, { showDelete: true }))} -
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)", From 479028673fa620b5edbcbefe7b1499a689216a31 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:13:50 +0800 Subject: [PATCH 06/42] =?UTF-8?q?fix(slark):=20bump=20nav=20row=20height?= =?UTF-8?q?=2028px=20=E2=86=92=2032px=20for=20real=20breathing=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit py-[5px] still felt cramped per user feedback. Commit to Discord- density instead of trying to split the difference. - py-[5px] → py-2 (8px vertical) : row height ~28px → ~32px - gap-2 → gap-2.5 : 2px more icon-to-label breathing - AGENTS.md compact nav list spec updated to match (32px row height) Made-with: Cursor --- AGENTS.md | 2 +- apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 495d9918..dc619784 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,7 +204,7 @@ - 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: **0** — do not use `space-y-1` / `gap-1` between rows; the rounded selected/hover background already provides visual separation. Extra gap breaks the rows into disconnected pills. - Row radius: `rounded-md` (8px) — `rounded-lg` (12px) looks pill-like inside narrow (≤240px) sidebars. - - Row padding: `pl-2 pr-2 py-[5px]` with 13px text + 14px icon (`h-3.5 w-3.5`) yields a ~28px row height — matches Slack's breathing room without feeling loose. Use a 14px icon (not 16px) so the icon doesn't dominate 13px label text in narrow 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-1` — aligns with the row container padding; `pt-3` (12px) gives breathing room between sections. - 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. diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 038a2ff1..6a794c9e 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 rounded-md pl-2 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 From 058b7854767f7e618eba9a82017f9cfd78da6833 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:16:35 +0800 Subject: [PATCH 07/42] chore(slark): localize visible mock data to Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI chrome (搜索 / 频道 / 设置) was already Chinese via i18n, but the mock channel names, descriptions, and workspace name were still hardcoded English, creating a jarring mix in the sidebar demo. Localizes the surfaces that show up in the nav list: - Workspace: Acme Engineering → 星云工程 - ch-welcome: welcome → 欢迎 (+ Chinese description) - ch-showcase: design-showcase → 设计展示 (+ Chinese description) - dm-agent-1: CodeBot → 代码助手 - dm-agent-2: DesignReviewer → 设计评审 Keeps user names (Alice Chen / Bob Kim / Charlie Park / Diana Wu) and agent/template/skill content + mock message bodies in English — those are out of scope for this pass and can be done in a follow-up if a fully localized demo is desired. Made-with: Cursor --- apps/slark/src/renderer/src/mock/data.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/slark/src/renderer/src/mock/data.ts b/apps/slark/src/renderer/src/mock/data.ts index ed4a559a..717e3af4 100644 --- a/apps/slark/src/renderer/src/mock/data.ts +++ b/apps/slark/src/renderer/src/mock/data.ts @@ -13,7 +13,7 @@ import type { export const mockWorkspace: Workspace = { id: "ws-1", - name: "Acme Engineering", + name: "星云工程", avatar: undefined, createdAt: Date.now() - 86400000 * 30, }; @@ -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: "欢迎", + description: "欢迎加入 Nexu!向你的队友和智能体打个招呼吧。", type: "channel", members: [user1Ref, user2Ref, user3Ref, agent1Ref, agent2Ref], lastMessageAt: Date.now() - 60000, @@ -256,8 +256,8 @@ export const mockChannels: Channel[] = [ }, { id: "ch-showcase", - name: "design-showcase", - description: "Every message surface the component library ships, in one feed.", + name: "设计展示", + description: "组件库提供的每一种消息形态,都集中在这条频道里。", type: "channel", members: [user1Ref, user2Ref, user3Ref, agent1Ref, agent2Ref], lastMessageAt: Date.now() - 45000, @@ -284,7 +284,7 @@ export const mockChannels: Channel[] = [ }, { id: "dm-agent-1", - name: "CodeBot", + name: "代码助手", type: "dm", members: [user1Ref, agent1Ref], lastMessageAt: Date.now() - 180000, @@ -293,7 +293,7 @@ export const mockChannels: Channel[] = [ }, { id: "dm-agent-2", - name: "DesignReviewer", + name: "设计评审", type: "dm", members: [user1Ref, agent2Ref], lastMessageAt: Date.now() - 3600000, From 170111674205f93aff74f415f48cbbc24e1935f4 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:18:08 +0800 Subject: [PATCH 08/42] fix(slark): 2px row gap in compact nav list (tight but not touching) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous spec said 0 row gap on the assumption that rounded selected bg already separates rows. But when two adjacent rows are both filled at the same time (hover + hover, focus-ring + hover, unread-bg + hover), the fills physically touch and look like one smudged block — visually reported by user on welcome (focused) and design-showcase (hovered) row pair. New rule: space-y-0.5 (2px) is the minimum gap that prevents any two adjacent filled states from butting into each other, while staying tight enough to read as one coherent list (not a chain of disconnected pills). - ChatSidebar: wrap row lists in
- AGENTS.md "Compact nav list density": change row gap rule from 0 → space-y-0.5, with rationale about adjacent filled states. Made-with: Cursor --- AGENTS.md | 2 +- apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc619784..1928c61e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,7 @@ - **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: **0** — do not use `space-y-1` / `gap-1` between rows; the rounded selected/hover background already provides visual separation. Extra gap breaks the rows into disconnected pills. + - 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. diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 6a794c9e..5f43b942 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx @@ -191,7 +191,7 @@ export function ChatSidebar(): React.ReactElement { {t("chat.pinned")}
- {pinnedChannels.map((c) => renderRow(c))} +
{pinnedChannels.map((c) => renderRow(c))}
)} @@ -214,7 +214,9 @@ export function ChatSidebar(): React.ReactElement {
- {channelList.map((c) => renderRow(c, { showDelete: true }))} +
+ {channelList.map((c) => renderRow(c, { showDelete: true }))} +
From 49997678a763bf2e8a1f7298dc31b4bf4a8d12b0 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:21:00 +0800 Subject: [PATCH 09/42] fix(slark): hardcode section labels to English + more header-to-row gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues reported on the channel sidebar in zh-CN locale: 1. "频道" section header looked awkward because the styling (uppercase tracking-wider) is inherently English-first. CJK has no case, so text-transform: uppercase is a no-op, and the wide letter-spacing looks wrong on CJK glyphs. 2. Only pb-1 (4px) between the section label and the first row, which made the label feel like it was touching hover/selected backgrounds below it. Fixes: - Hardcode "Pinned" / "Channels" as English in ChatSidebar (matches Slack / Cursor / Linear convention in CJK locales — decorative category labels stay English regardless of app locale). - Bump section header padding: pb-1 → pb-2 (8px) so the label has clear separation from the first filled row below. AGENTS.md "Compact nav list density" updated with two new rules: - Section header language convention (decorative uppercase labels stay English across all locales, with rationale + escape hatch). - Section header spacing corrected to pb-2. Made-with: Cursor --- AGENTS.md | 3 ++- .../src/renderer/src/components/chat/ChatSidebar.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1928c61e..3249d93b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,8 @@ - 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-1` — aligns with the row container padding; `pt-3` (12px) gives breathing room between sections. + - Section header: `px-2 pt-3 pb-2` with `text-[10px] 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. + - 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. diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 5f43b942..e40df96d 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx @@ -187,17 +187,17 @@ export function ChatSidebar(): React.ReactElement {
{pinnedChannels.length > 0 && (
-
+
- {t("chat.pinned")} + Pinned
{pinnedChannels.map((c) => renderRow(c))}
)}
-
- {t("chat.channels")} +
+ Channels
From 9096f9d1c99fb1d94ba79fdee708343185b5da7a Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:26:49 +0800 Subject: [PATCH 12/42] style(slark): align search width with list rows + subtle border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes on the channel sidebar: 1. Search input was at px-3 (12px inset) while the list container below was at px-2 (8px inset). The 4px mismatch made the search look like a different component from the list it filters — the selection fill on "welcome" row visibly extended past the search's right edge. Align search wrapper to px-2. 2. Search input had border-transparent at rest, which made it read as a passive pill rather than an editable control. Per AGENTS.md border tokens, a sidebar search should show --color-border-subtle (rgba 0 0 0 / 0.06) — just enough to assert "this is an input" without shouting. Focus state unchanged (border-transparent + ring-1 ring-nav-ring). AGENTS.md "Compact nav list density" updated with a new rule for top-of-list search inputs: match px-2 with the list container, and always show border-border-subtle at rest, not border-transparent. Made-with: Cursor --- AGENTS.md | 1 + apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb06fd66..8ee4e75b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,7 @@ - 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". diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 1d40b27c..9e498cfc 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx @@ -173,13 +173,13 @@ 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" />
From 4974522620e369622e9e6ee8bc3bcd5862c3ed42 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 19:30:03 +0800 Subject: [PATCH 13/42] style(slark): apply component-library frosted glass to ActivityBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leftmost ActivityBar was a solid bg-nav-surface with no border, reading as a flat slab next to the white chat panel. Adopt the same frosted-glass recipe the rest of the codebase uses for floating chrome (nav bars, landing headers, floating toasts): translucent surface + backdrop-blur + subtle border. ActivityBar class change: border-r-0 bg-nav-surface → border-r border-border-subtle bg-nav-surface/75 backdrop-blur-md Keeps the nav-surface gray character (not switching to a new color), just makes it 75% alpha with medium blur and a hair-thin right border to separate from the white chat panel. AGENTS.md new section "Frosted glass (translucent surfaces)" under Design & styling > Elevation & shadow: - Canonical recipes for stacked nav vs sidebar/activity bar - Alpha range (70–92%) and rationale for both bounds - Always-pair-with-backdrop-blur-md rule + when to deviate - Always-add-subtle-border rule (prevents bleeding into neighbors) - Note on Electron vibrancy: classes work with or without, so apply them proactively — the effect lights up automatically if vibrancy is configured later. - Explicit anti-pattern: do not use on content panels (chat body, page cards) — those must stay fully opaque for readability. Made-with: Cursor --- AGENTS.md | 9 +++++++++ .../src/renderer/src/components/layout/ActivityBar.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8ee4e75b..d4e90160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,6 +319,15 @@ - 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). Preserve the panel's native surface tone by using that surface at 70–90% alpha instead of switching to a different color. +- Alpha range: **70–92%**. Below 70% the chrome loses legibility; above 92% it reads as solid and the translucency is pointless. +- 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. +- In Electron apps the effect is strongest when `BrowserWindow` has `vibrancy: "sidebar"` (macOS) so the blur can reach the desktop. Without vibrancy the classes still render fine but the blur only affects in-window content behind the chrome — keep the classes anyway so the treatment light-up automatically if vibrancy is added later. +- 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. + ## Accessibility and UX expectations - Accessibility is actively tested with `vitest-axe`. - Prefer semantic roles and label associations that work with Testing Library queries. diff --git a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx index 8c892248..2611f568 100644 --- a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx +++ b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx @@ -43,7 +43,7 @@ export function ActivityBar(): React.ReactElement { const [menuOpen, setMenuOpen] = useState(false); return ( - + From d9c3c0aa58c123758826601ff283cb7514efdb09 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 20:03:18 +0800 Subject: [PATCH 14/42] feat(slark): real frosted-glass chrome via native vibrancy + white content canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up actual desktop-blur frosted glass on the macOS ActivityBar instead of the previous CSS-only treatment that had nothing to blur through. - main/index.ts: enable `vibrancy: "sidebar"` + `visualEffectState: "active"` on macOS; drop the opaque `backgroundColor` so vibrancy can show through (non-mac still sets `#fafafa` for a no-flash startup) - renderer chain goes transparent so vibrancy reaches the chrome: - index.html body: drop `bg-background` - globals.css: `html / body / #root { background: transparent }` - AppLayout root: drop `bg-background` - AppLayout `
` gets `bg-surface-1` so every routed view has a solid white content canvas (Team / Settings / Runtimes were missing bg and silently showed vibrancy through as a "fake frosted" bug) - ActivityBar: `bg-nav-surface/55` → `bg-nav/80` — the surface-2 token is gray, which read as "dim" at low alpha; surface-1 (white) at 80% gives the lightly-frosted-white look that matches Slack / Cursor / Finder Made-with: Cursor --- apps/slark/src/main/index.ts | 12 +++++++++++- apps/slark/src/renderer/index.html | 2 +- apps/slark/src/renderer/src/app/globals.css | 13 +++++++++++++ .../renderer/src/components/layout/ActivityBar.tsx | 2 +- .../renderer/src/components/layout/AppLayout.tsx | 4 ++-- 5 files changed, 28 insertions(+), 5 deletions(-) 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/globals.css b/apps/slark/src/renderer/src/app/globals.css index 307713e2..f5fb7422 100644 --- a/apps/slark/src/renderer/src/app/globals.css +++ b/apps/slark/src/renderer/src/app/globals.css @@ -5,6 +5,19 @@ @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); diff --git a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx index 2611f568..cfb2c68a 100644 --- a/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx +++ b/apps/slark/src/renderer/src/components/layout/ActivityBar.tsx @@ -43,7 +43,7 @@ export function ActivityBar(): React.ReactElement { const [menuOpen, setMenuOpen] = useState(false); return ( - + 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 ( -
+
-
+
From 5475be697eb051464df321ba8a1614ffc5a9bf2a Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 20:03:32 +0800 Subject: [PATCH 15/42] feat(slark): channel header with inline tabs + members chip, drop subtitles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the channel header into a single unified chrome block that replaces the old two-row title-plus-toolbar layout: - Unified header: title row (`h-9`) + tabs row share one `border-b` block with no divider between them, reading as one chrome surface instead of two stacked bars - Members as inline chip: Users icon + count sits immediately right of the channel name (not pushed to the far right), and opens AddMembersDialog on click. Replaces both the old right-aligned UserPlus button and the separate "Members" tab - Tabs: `Messages / Files / Artifacts` (Members removed since it's now the chip). Compact size — `h-6 px-2 text-[12px]` triggers with 12px icons — so they sit comfortably under the 15px title. Labels hardcoded English across locales, matching the CHANNELS / PINNED convention - Files + Artifacts tabs render EmptyState placeholders (hardcoded English strings — the previously-added chat.tab.* i18n keys were never wired after moving to hardcoded labels and are removed) - Drop channel description rendering entirely from the header — it was redundant with the title for most channels and stole horizontal space from the members chip - Hover fill on title + members buttons: `hover:bg-accent` → `hover:bg-surface-2`. Tailwind's `bg-accent` resolves to the raw brand teal, not our design-system `--color-accent` near-black — the old class flooded the whole row with teal on hover - Rename welcome channel: "welcome" → "Welcome to Nexu!", clear description Made-with: Cursor --- .../renderer/src/components/chat/ChatView.tsx | 151 ++++++++++++++---- apps/slark/src/renderer/src/mock/data.ts | 4 +- 2 files changed, 119 insertions(+), 36 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/ChatView.tsx b/apps/slark/src/renderer/src/components/chat/ChatView.tsx index 57d7f26d..6111b04e 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatView.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatView.tsx @@ -1,6 +1,8 @@ import { 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 } from "@nexu-design/ui-web"; + import { useT } from "@/i18n"; import { useChatStore } from "@/stores/chat"; import { useWorkspaceStore } from "@/stores/workspace"; @@ -110,45 +112,126 @@ 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 + + +
+ + + + + + + +
+ } + 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" && ( Date: Mon, 20 Apr 2026 20:03:43 +0800 Subject: [PATCH 16/42] docs(agents): codify frosted-glass, hover-fill, and chat-header rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the new rules that came out of the Slark chrome + chat-header pass so the next contributor doesn't rediscover them: - Frosted glass: always start from surface-1 (white), never surface-2 (gray); tighter 75–85% alpha over native vibrancy; full Electron integration recipe (vibrancy config, transparent parent chain, opaque content panels as the inverse gotcha) - Row-level hover fill: ban `hover:bg-accent` (resolves to brand teal), prefer `hover:bg-surface-2`; grep new code for this before landing - Tab labels stay English across locales for page-level navigation tabs (chat header, settings, skills) — same convention as decorative uppercase category labels - Compact tab size inside a chat/channel header: `TabsList h-7 p-0.5`, `TabsTrigger h-6 px-2 text-[12px]`, `size-3` icons - Unified chat/channel header: title row + tabs row share one border-b container, no divider between them Made-with: Cursor --- AGENTS.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d4e90160..b90bd634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,6 +213,7 @@ - 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). @@ -321,12 +322,23 @@ ### 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). Preserve the panel's native surface tone by using that surface at 70–90% alpha instead of switching to a different color. -- Alpha range: **70–92%**. Below 70% the chrome loses legibility; above 92% it reads as solid and the translucency is pointless. +- 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. -- In Electron apps the effect is strongest when `BrowserWindow` has `vibrancy: "sidebar"` (macOS) so the blur can reach the desktop. Without vibrancy the classes still render fine but the blur only affects in-window content behind the chrome — keep the classes anyway so the treatment light-up automatically if vibrancy is added later. +- **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`. @@ -490,6 +502,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` From 5f66f0b7eb395b8b1aa49542dcfa5ec0e502222d Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 20:47:51 +0800 Subject: [PATCH 17/42] =?UTF-8?q?feat(slark):=20IM=20showcase,=20unified?= =?UTF-8?q?=20content=20cards,=20topic=20=E2=86=92=20side=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product changes - Default /chat route now lands on ch-showcase so the demo conversation is visible at launch; mock data adds a "Yesterday" system join EventNotice, a kickoff message, and an @mention-me message so highlighted rows and consecutive-message grouping are exercised out of the box. - Unify every rich card's width to w-full max-w-[640px] (code, diff, tool-result, action, approval, progress, topic) so the feed has one card lane and cards are instantly distinguishable from chat bubbles. - ApprovalBlock, ActionCard, ProgressBlock retuned to design-system semantic tokens (info / success / warning / error + their subtle variants) and ui-web primitives (Button, Progress replaced with checklist) — no more hardcoded blue/green/red/amber. - Code and diff blocks collapse into a compact single-line pill (Cursor style) via a shared CollapsedContentRow; clicking expands via the existing ContentDetailOverlay. Keeps the chat feed scannable. - ProgressBlock rebuilt Cursor-style: drop the bar and the colored step dots, switch to Check / Loader2 / Circle icons in monochrome with 12px step spacing; "3 / 5" counter replaces the big percentage. Status color is carried purely by label styling (muted + strikethrough for done, heading for active, tertiary for pending). - TopicCard: brand-tinted border on white (not a brand wash) so the status pill owns the semantic color; hover deepens the border and adds shadow-sm for affordance. Prevents duplicate "brand flood" across four stacked topic cards and unblocks the highlighted-mention row styling from fighting the card surface. Topic → side-panel interaction (new) - Clicking a topic card now opens a persistent right-side detail panel instead of doing nothing. Four tabs: Artifacts / Members / Files / Pinned, matching the storybook chat-side-panel prototype (ca6c295). - Layout is push (not overlay): ChatView wraps the Messages tab in a flex row and animates the panel column width 0 → 380px over 200ms (ease-standard). No backdrop / scrim — the message list reflows. - State lives in ChatView and resets on channel switch. A dedicated onTopicOpen callback threads through MessageList → ContentBlocks → TopicBlock; it is intentionally separate from onExpand (which drives the fullscreen ContentDetailOverlay for code / diff / image) because topics are tracked threads, not transient previews. - TopicDetailPanel composes existing ui-web primitives (DetailPanel, Tabs, EmptyState) — no new primitives added. Dev DX - apps/slark/electron.vite.config.ts aliases @nexu-design/ui-web to packages/ui-web/src/index.ts (matching storybook's main.ts) so edits to ui-web primitives reflect instantly via HMR. Without this the renderer consumed the stale dist bundle from package.json's "main": "./dist/index.js", which silently blocked newly-added Tailwind utility classes in primitives from reaching the DOM even though Tailwind generated CSS for them. Made-with: Cursor --- apps/slark/electron.vite.config.ts | 9 + apps/slark/src/renderer/src/app/App.tsx | 6 +- .../renderer/src/components/chat/ChatView.tsx | 83 +++- .../src/components/chat/ContentBlocks.tsx | 447 ++++++++---------- .../src/components/chat/MessageList.tsx | 20 +- .../src/components/chat/TopicDetailPanel.tsx | 199 ++++++++ apps/slark/src/renderer/src/mock/data.ts | 33 ++ packages/ui-web/src/primitives/topic-card.tsx | 13 +- 8 files changed, 557 insertions(+), 253 deletions(-) create mode 100644 apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx 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/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/components/chat/ChatView.tsx b/apps/slark/src/renderer/src/components/chat/ChatView.tsx index 6111b04e..142ec051 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatView.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatView.tsx @@ -1,17 +1,23 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AtSign, Bot, FolderOpen, Globe, MessageSquare, Sparkles, Users } from "lucide-react"; -import { EmptyState, Tabs, TabsContent, TabsList, TabsTrigger } from "@nexu-design/ui-web"; +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(); @@ -22,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; @@ -197,9 +245,34 @@ export function ChatView(): React.ReactElement {
- - - + + {/* + 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 && } +
diff --git a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx index 571a9e57..74f126b9 100644 --- a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx +++ b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx @@ -15,7 +15,8 @@ import { CheckCircle2, ChevronDown, ChevronRight, - Copy, + Circle, + FileCode2, GitPullRequestArrow, Loader2, ShieldQuestion, @@ -24,11 +25,26 @@ import { } from "lucide-react"; import { useState } from "react"; +/** + * Content-block props. + * + * Two "expansion" callbacks live side-by-side on purpose: + * + * - `onExpand` routes heavy/ephemeral content (code, diff, image) into a + * fullscreen modal overlay. The reader briefly leaves the chat context + * to inspect a payload and then returns. + * + * - `onTopicOpen` routes *topic cards* into the persistent right-side + * detail panel. A topic is a tracked thread, not a blob to preview, so + * it stays in-flow next to the message list and pushes the chat + * (never overlays). These are semantically different interactions and + * must not be multiplexed through the same callback. + */ interface ContentBlockRendererProps { block: ContentBlock; - isMe: boolean; onApprovalAction?: (id: string, action: "approved" | "rejected") => void; onExpand?: (block: ContentBlock) => void; + onTopicOpen?: (block: Extract) => void; } function formatFileSize(bytes: number): string { @@ -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,62 @@ function ApprovalBlock({ return (
-

{block.title}

+

{block.title}

{block.description && ( -

+

{block.description}

)}
-
+
{block.status === "pending" && (
)} {block.status === "approved" && ( -
- +
+ Approved
)} {block.status === "rejected" && ( -
- +
+ Rejected
)} @@ -511,63 +457,78 @@ 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" && ( +
    ); @@ -575,9 +536,9 @@ function ProgressBlock({ export function ContentBlockRenderer({ block, - isMe, onApprovalAction, onExpand, + onTopicOpen, }: ContentBlockRendererProps): React.ReactElement { const handleExpand = (): void => onExpand?.(block); @@ -593,7 +554,7 @@ export function ContentBlockRenderer({ case "file": return ; case "code": - return ; + return ; case "action": return ; case "tool-result": @@ -605,6 +566,8 @@ export function ContentBlockRenderer({ case "progress": return ; case "topic": - return ; + return ( + onTopicOpen(block) : undefined} /> + ); } } 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..43327d7d --- /dev/null +++ b/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx @@ -0,0 +1,199 @@ +import { + DetailPanel, + DetailPanelCloseButton, + DetailPanelHeader, + DetailPanelTitle, + EmptyState, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + cn, +} from "@nexu-design/ui-web"; +import { Hash, Paperclip, Pin, Sparkles, Users } from "lucide-react"; + +import type { ContentBlock } 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 container animates `width: 0 → 400px` so the message list is + * *pushed*, never overlaid. No backdrop, no scrim. + * - Four tabs (Artifacts / Members / Files / Pinned) match the storybook + * scenario and the product review. Labels stay English regardless of + * locale — same rule as the chat header tabs. + * - `bg-surface-0` (one step below `surface-1` chat bg) gives the panel a + * slight 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; + + return ( + + + +
    +
    + + {topic.title} + + {status && ( + + {status.label} + + )} +
    +

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

    +
    + +
    + + +
    + + + + Artifacts + + + + Members + + + + Files + + + + Pinned + + +
    + + + {topic.preview ? ( +
    +

    + Summary +

    +

    + {topic.preview} +

    +
    + ) : ( + } + title="No artifacts yet" + description="As this topic produces outputs, they'll appear here." + /> + )} +
    + + + {topic.participants.length > 0 ? ( +
      + {topic.participants.map((initials, idx) => ( +
    • +
      + {initials} +
      +
      + {initials} +
      +
    • + ))} +
    + ) : ( +
    + } + title="No members" + description="This topic has no participants yet." + /> +
    + )} +
    + + + } + title="No files" + description="Files shared in this topic will appear here." + /> + + + + } + title="Nothing pinned" + description="Pin a message in this topic to keep it handy." + /> + +
    +
    + ); +} diff --git a/apps/slark/src/renderer/src/mock/data.ts b/apps/slark/src/renderer/src/mock/data.ts index 069bfbbe..f0a273d3 100644 --- a/apps/slark/src/renderer/src/mock/data.ts +++ b/apps/slark/src/renderer/src/mock/data.ts @@ -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", @@ -575,6 +598,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", diff --git a/packages/ui-web/src/primitives/topic-card.tsx b/packages/ui-web/src/primitives/topic-card.tsx index a277c787..f902055b 100644 --- a/packages/ui-web/src/primitives/topic-card.tsx +++ b/packages/ui-web/src/primitives/topic-card.tsx @@ -85,7 +85,18 @@ export const TopicCard = 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} From e59c02827c14a43159c563ac173942ff432a1f32 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Mon, 20 Apr 2026 20:52:35 +0800 Subject: [PATCH 18/42] feat(slark): topic panel shows reply thread with images + link previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-side TopicDetailPanel's primary tab was a placeholder "Artifacts" surface with a single summary card. What people expect on a topic panel is the reply conversation itself — with inline images, link previews, agent messages, and the originating opening context at the top. This patch makes that happen. Data - Extend the topic content-block type with an optional `thread` (`TopicThreadMessage[]`) so a topic can carry canned replies. Each reply is { id, author, initials, createdAtLabel, text?, image?, link?, isAgent?, accent? } — pre-baked display strings for mock data; no timestamps, no driver logic. - Add canned threads to three topics in ch-showcase: * topic-1 (Billing retry storms — NEEDS REVIEW): 4 replies, includes a Datadog dashboard link and a GitHub PR link; the fourth reply is from the "Coder" agent. * topic-2 (Landing redesign — ACTIVE): 3 replies, includes an inline hero-exploration image and a Linear issue link. * topic-3 (Auth rotation — DONE): 3 replies, includes a Notion report link. Panel UI - Replace the "Artifacts" tab with a **Thread** tab (new default). Renders the reply list with a small local component instead of the full ChatMessage primitive — at 380px wide the panel needs tighter padding, smaller avatars, and a custom link-card style, and overriding ChatMessage for all of that would be more brittle than a purpose-built reply row. - If `topic.preview` exists, render it as an "Opening context" card pinned above the replies, mirroring how Slack / Linear surface the originating message at the top of a thread view. - Agents in the thread get a Bot glyph avatar on `bg-brand-subtle`, a green "Agent" badge, and the assignee accent color applied to their author name — same visual language as the main chat feed. - Inline images use the shared `ImageAttachment` primitive; links render as a compact preview card (icon + title + description + host) that opens in a new tab. - Files tab now aggregates images and links across the thread instead of showing EmptyState — images render as a 2-col grid, links as a vertical list of the same preview card used inline. Falls back to EmptyState when the thread carries nothing. - Tab order reset to Thread / Files / Members / Pinned; Artifacts was dropped because it duplicated Files once threads carry the real payload. Made-with: Cursor --- .../src/components/chat/TopicDetailPanel.tsx | 276 +++++++++++++++--- apps/slark/src/renderer/src/mock/data.ts | 111 +++++++ apps/slark/src/renderer/src/types/index.ts | 21 ++ 3 files changed, 365 insertions(+), 43 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx b/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx index 43327d7d..8cf7f8fa 100644 --- a/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx +++ b/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx @@ -4,15 +4,16 @@ import { DetailPanelHeader, DetailPanelTitle, EmptyState, + ImageAttachment, Tabs, TabsContent, TabsList, TabsTrigger, cn, } from "@nexu-design/ui-web"; -import { Hash, Paperclip, Pin, Sparkles, Users } from "lucide-react"; +import { Bot, ExternalLink, Hash, MessageSquareMore, Paperclip, Pin, Users } from "lucide-react"; -import type { ContentBlock } from "@/types"; +import type { ContentBlock, TopicThreadMessage } from "@/types"; type TopicBlock = Extract; @@ -24,16 +25,18 @@ interface TopicDetailPanelProps { /** * Right-side detail panel for a clicked topic card. * - * Layout contract (see PR #34 rationale, mirrored from the - * chat-side-panel storybook prototype): + * 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 container animates `width: 0 → 400px` so the message list is - * *pushed*, never overlaid. No backdrop, no scrim. - * - Four tabs (Artifacts / Members / Files / Pinned) match the storybook - * scenario and the product review. Labels stay English regardless of - * locale — same rule as the chat header tabs. - * - `bg-surface-0` (one step below `surface-1` chat bg) gives the panel a - * slight depth cue without introducing a new surface token. + * 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< @@ -49,6 +52,18 @@ const STATUS_BADGE: Record< 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 ( @@ -96,29 +111,29 @@ export function TopicDetailPanel({ topic, onClose }: TopicDetailPanelProps): Rea - +
    - - Artifacts + + Thread - - Members + + Files - - Files + + Members
    - - {topic.preview ? ( -
    -

    - Summary -

    -

    - {topic.preview} -

    + + + + + + {hasAnyFiles ? ( +
    + {sharedImages.length > 0 && ( +
    +

    + Images ({sharedImages.length}) +

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

    + Links ({sharedLinks.length}) +

    +
      + {sharedLinks.map((m) => ( +
    • + +
    • + ))} +
    +
    + )}
    ) : ( - } - title="No artifacts yet" - description="As this topic produces outputs, they'll appear here." - /> +
    + } + title="No files" + description="Files shared in this topic will appear here." + /> +
    )}
    @@ -178,14 +226,6 @@ export function TopicDetailPanel({ topic, onClose }: TopicDetailPanelProps): Rea )} - - } - title="No files" - description="Files shared in this topic will appear here." - /> - - } @@ -197,3 +237,153 @@ export function TopicDetailPanel({ topic, onClose }: TopicDetailPanelProps): Rea ); } + +/** + * 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/mock/data.ts b/apps/slark/src/renderer/src/mock/data.ts index f0a273d3..df9cd867 100644 --- a/apps/slark/src/renderer/src/mock/data.ts +++ b/apps/slark/src/renderer/src/mock/data.ts @@ -639,6 +639,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", @@ -650,6 +695,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", @@ -661,6 +742,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..665a3eb2 100644 --- a/apps/slark/src/renderer/src/types/index.ts +++ b/apps/slark/src/renderer/src/types/index.ts @@ -146,8 +146,29 @@ 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[]; }; +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; From 36d5de72e66dab6b5ac56ca6ea4293b41485a570 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 10:50:36 +0800 Subject: [PATCH 19/42] refactor(ui-web): unify chat attachment widths to 360px File / image / voice / video attachments now share a single 360px frame so stacked attachments in a chat feed line up cleanly instead of wobbling between 320 / 340 / 360. ImageGallery keeps its 480px grid. Adds a chat-feed width-tier rule to AGENTS.md (card tier 640px, attachment tier 360px) so future primitives do not drift. Made-with: Cursor --- AGENTS.md | 4 ++++ apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx | 4 ++-- packages/ui-web/src/primitives/file-attachment.tsx | 2 +- packages/ui-web/src/primitives/image-attachment.tsx | 4 ++-- packages/ui-web/src/primitives/voice-message.tsx | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b90bd634..3d0e8c5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,6 +220,10 @@ 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. diff --git a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx index 74f126b9..e6f22348 100644 --- a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx +++ b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx @@ -104,8 +104,8 @@ function ImageBlock({ 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( ref={ref} data-slot="voice-message" className={cn( - "flex w-[320px] max-w-full flex-col gap-2 rounded-lg border border-border bg-surface-1 px-3 py-2.5", + "flex w-[360px] max-w-full flex-col gap-2 rounded-lg border border-border bg-surface-1 px-3 py-2.5", className, )} {...props} From a47a126e50fa2805706fb4aa61b9e9ba0d5b09dd Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 10:54:59 +0800 Subject: [PATCH 20/42] feat(ui-web): hide voice-message transcript behind a hover toggle Voice notes used to always expand their transcript below the waveform, which made a channel of voice recaps feel heavy. Now the transcript is hidden by default and a small captions icon fades in on hover (top-right of the waveform row). Clicking toggles the transcript inline; the button stays visible + active-styled while expanded, and ARIA aria-expanded / aria-label describe the state. Callers that want the old behaviour can pass defaultTranscriptOpen. Made-with: Cursor --- .../src/stories/voice-message.stories.tsx | 18 +++++++- .../src/primitives/voice-message.test.tsx | 27 +++++++++++- .../ui-web/src/primitives/voice-message.tsx | 41 +++++++++++++++++-- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/storybook/src/stories/voice-message.stories.tsx b/apps/storybook/src/stories/voice-message.stories.tsx index 39c5d8b3..eb2ce6de 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 = { 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 1f5f1df1..55501e4b 100644 --- a/packages/ui-web/src/primitives/voice-message.tsx +++ b/packages/ui-web/src/primitives/voice-message.tsx @@ -1,4 +1,4 @@ -import { Play, Volume2 } from "lucide-react"; +import { Captions, Play, Volume2 } from "lucide-react"; import * as React from "react"; import { cn } from "../lib/cn"; @@ -12,8 +12,16 @@ const DEFAULT_WAVEFORM = [3, 6, 10, 14, 8, 12, 18, 22, 16, 10, 14, 20, 18, 8, 12 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 +42,8 @@ export const VoiceMessage = React.forwardRef( className, duration, transcript, + defaultTranscriptOpen = false, + transcriptToggleLabel, waveform = DEFAULT_WAVEFORM, onPlay, state = "idle", @@ -41,12 +51,17 @@ 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 (
    ( /> ))}
    + {hasTranscript ? ( + + ) : null} {duration}
    - {transcript ? ( + {hasTranscript && transcriptOpen ? (

    {transcript} From 2e165d1b42cd2e08218ab457864420a46bc9175c Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:00:27 +0800 Subject: [PATCH 21/42] refactor(ui-web): voice-message play hover, transcript trigger, bar height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from the IM showcase review: 1. Play button hover no longer flips to the brand accent fill (which read as a jarring teal disc on a light card). It now matches the video attachment's language — stable background, subtle scale-110 on hover, same transition timing. 2. The hover-only captions icon was ambiguous. Replace it with a plain-text toggle rendered BELOW the card ("Show transcript" / "Hide transcript" with a rotating chevron). Always visible when a transcript is present, so the affordance is obvious without hunting. 3. Waveform bars were too tall for a compact feed. Shrink the bar container from h-7 (28px) to h-5 (20px), halve the default waveform heights, and drop the bar offset from +6 to +3. The CustomWaveform story values are rescaled to match the new range. Made-with: Cursor --- .../src/stories/voice-message.stories.tsx | 2 +- .../ui-web/src/primitives/voice-message.tsx | 117 ++++++++++-------- 2 files changed, 63 insertions(+), 56 deletions(-) diff --git a/apps/storybook/src/stories/voice-message.stories.tsx b/apps/storybook/src/stories/voice-message.stories.tsx index eb2ce6de..c113f580 100644 --- a/apps/storybook/src/stories/voice-message.stories.tsx +++ b/apps/storybook/src/stories/voice-message.stories.tsx @@ -56,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: [3, 6, 9, 12, 14, 15, 14, 12, 9, 6, 3, 6, 9, 12, 14, 15, 14, 12, 9, 6], }, }; diff --git a/packages/ui-web/src/primitives/voice-message.tsx b/packages/ui-web/src/primitives/voice-message.tsx index 55501e4b..c0070e84 100644 --- a/packages/ui-web/src/primitives/voice-message.tsx +++ b/packages/ui-web/src/primitives/voice-message.tsx @@ -1,13 +1,15 @@ -import { Captions, 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. + * so the visual rhythm is consistent across renders. Values are tuned for a + * compact `h-5` (20px) bar container — keep custom waveforms in roughly the + * 2–16 range to avoid overflowing the row. */ -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 = [2, 4, 6, 9, 5, 8, 12, 14, 11, 7, 9, 13, 12, 5, 8, 4, 9, 7, 3, 5]; export interface VoiceMessageProps extends React.HTMLAttributes { /** Formatted duration label, e.g. "0:24". */ @@ -60,68 +62,73 @@ export const VoiceMessage = React.forwardRef(

    -
    - -
    - {waveform.map((height, index) => ( - - ))} -
    - {hasTranscript ? ( +
    +
    +
    + {waveform.map((height, index) => ( + + ))} +
    + + {duration} + +
    + {hasTranscript && transcriptOpen ? ( +

    + + {transcript} +

    ) : null} - - {duration} -
    - {hasTranscript && transcriptOpen ? ( -

    - - {transcript} -

    + {hasTranscript ? ( + ) : null}
    ); From 28a08e1a2d588c8596ab3d84c9330837a3e29c63 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:03:58 +0800 Subject: [PATCH 22/42] refactor(ui-web): further shrink voice-message waveform for decoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waveform is purely ornamental — it signals "this is audio" and nothing more. The previous h-5 bar row still read as content. Drop the bar container to h-4 (16px), narrow each bar from 3px → 2px, halve the default waveform heights, and reduce the render offset from +3 to +2 so the tallest bar sits at ~9px. Rescale the CustomWaveform story values to match the new range. Made-with: Cursor --- .../src/stories/voice-message.stories.tsx | 2 +- .../ui-web/src/primitives/voice-message.tsx | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/storybook/src/stories/voice-message.stories.tsx b/apps/storybook/src/stories/voice-message.stories.tsx index c113f580..ad72c2c5 100644 --- a/apps/storybook/src/stories/voice-message.stories.tsx +++ b/apps/storybook/src/stories/voice-message.stories.tsx @@ -56,6 +56,6 @@ export const Playing: Story = { export const CustomWaveform: Story = { args: { - waveform: [3, 6, 9, 12, 14, 15, 14, 12, 9, 6, 3, 6, 9, 12, 14, 15, 14, 12, 9, 6], + waveform: [2, 3, 5, 7, 8, 9, 8, 7, 5, 3, 2, 3, 5, 7, 8, 9, 8, 7, 5, 3], }, }; diff --git a/packages/ui-web/src/primitives/voice-message.tsx b/packages/ui-web/src/primitives/voice-message.tsx index c0070e84..78cf2081 100644 --- a/packages/ui-web/src/primitives/voice-message.tsx +++ b/packages/ui-web/src/primitives/voice-message.tsx @@ -4,12 +4,13 @@ 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. Values are tuned for a - * compact `h-5` (20px) bar container — keep custom waveforms in roughly the - * 2–16 range to avoid overflowing the row. + * 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-4` (16px) bar container; keep custom + * waveforms in roughly the 1–10 range to avoid overflowing the row. */ -const DEFAULT_WAVEFORM = [2, 4, 6, 9, 5, 8, 12, 14, 11, 7, 9, 13, 12, 5, 8, 4, 9, 7, 3, 5]; +const DEFAULT_WAVEFORM = [1, 2, 3, 5, 3, 4, 6, 7, 6, 4, 5, 7, 6, 3, 4, 2, 5, 4, 2, 3]; export interface VoiceMessageProps extends React.HTMLAttributes { /** Formatted duration label, e.g. "0:24". */ @@ -83,7 +84,7 @@ export const VoiceMessage = React.forwardRef(
    @@ -91,10 +92,10 @@ export const VoiceMessage = React.forwardRef( ))}
    From a01ea88e97cc6427a67a1e23745a441deb9d8c84 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:29:11 +0800 Subject: [PATCH 23/42] refactor(ui-web): drop voice-message waveform max height to ~6px Continuing the "waveform is decoration, not data" direction. Shrink the bar container from h-4 (16px) to h-3 (12px), cap default waveform values at 4 so the tallest bar renders at ~6px, and rescale the CustomWaveform story to match. Made-with: Cursor --- apps/storybook/src/stories/voice-message.stories.tsx | 2 +- packages/ui-web/src/primitives/voice-message.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/storybook/src/stories/voice-message.stories.tsx b/apps/storybook/src/stories/voice-message.stories.tsx index ad72c2c5..740f8212 100644 --- a/apps/storybook/src/stories/voice-message.stories.tsx +++ b/apps/storybook/src/stories/voice-message.stories.tsx @@ -56,6 +56,6 @@ export const Playing: Story = { export const CustomWaveform: Story = { args: { - waveform: [2, 3, 5, 7, 8, 9, 8, 7, 5, 3, 2, 3, 5, 7, 8, 9, 8, 7, 5, 3], + 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/voice-message.tsx b/packages/ui-web/src/primitives/voice-message.tsx index 78cf2081..f1b50faf 100644 --- a/packages/ui-web/src/primitives/voice-message.tsx +++ b/packages/ui-web/src/primitives/voice-message.tsx @@ -7,10 +7,10 @@ import { cn } from "../lib/cn"; * 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-4` (16px) bar container; keep custom - * waveforms in roughly the 1–10 range to avoid overflowing the row. + * 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 = [1, 2, 3, 5, 3, 4, 6, 7, 6, 4, 5, 7, 6, 3, 4, 2, 5, 4, 2, 3]; +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". */ @@ -84,7 +84,7 @@ export const VoiceMessage = React.forwardRef(
    From 8420af90d611470a9f63d55ae6744943b629e71d Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:42:57 +0800 Subject: [PATCH 24/42] feat(slark): fold agent work into one collapsible agent-run module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the Coder agent's five sequential artifacts (retry.ts code, client.ts diff, pnpm-test action, tool result, staging-rollout progress) into a single `agent-run` content block so the chat stays quiet. The renderer shows only the current (last) step expanded; earlier completed steps live behind a "Show N earlier steps" toggle. The approval card that follows stays in its own message on purpose — `AgentRunStep["block"]` narrows the union to code/diff/action/tool-result/ progress, so approval and topic blocks cannot be smuggled inside a run. Folding an ask into a "quiet" module would hide the thing that actually needs human attention. - types: add `agent-run` variant + `AgentRunStep` interface - mock: merge sc-8..sc-12 into one `sc-agent-run-1` message with 5 steps - ContentBlocks: add `AgentRunBlock` + `AgentRunStepRenderer`, wire the `agent-run` case into the switch Made-with: Cursor --- .../src/components/chat/ContentBlocks.tsx | 111 +++++++++++- apps/slark/src/renderer/src/mock/data.ts | 159 ++++++++---------- apps/slark/src/renderer/src/types/index.ts | 35 ++++ 3 files changed, 219 insertions(+), 86 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx index e6f22348..0a6afc89 100644 --- a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx +++ b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx @@ -1,4 +1,4 @@ -import type { ContentBlock } from "@/types"; +import type { AgentRunStep, ContentBlock } from "@/types"; import { Button, FileAttachment, @@ -534,6 +534,113 @@ function ProgressBlock({ ); } +/** + * 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, onApprovalAction, @@ -565,6 +672,8 @@ export function ContentBlockRenderer({ return ; case "progress": return ; + case "agent-run": + return ; case "topic": return ( onTopicOpen(block) : undefined} /> diff --git a/apps/slark/src/renderer/src/mock/data.ts b/apps/slark/src/renderer/src/mock/data.ts index df9cd867..b93c30bb 100644 --- a/apps/slark/src/renderer/src/mock/data.ts +++ b/apps/slark/src/renderer/src/mock/data.ts @@ -483,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", + /* + * 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: "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", - 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", diff --git a/apps/slark/src/renderer/src/types/index.ts b/apps/slark/src/renderer/src/types/index.ts index 665a3eb2..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; @@ -157,6 +171,27 @@ export type ContentBlock = 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; From d4c71feb41fb5d12ece0ba178af1040d4994b21f Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:45:40 +0800 Subject: [PATCH 25/42] style(slark): add surface-2 wash to agent-run shell for separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outer agent-run container was on surface-1 with a border, and its nested work cards (code, diff, action, tool-result, progress) were ALSO surface-1 with borders — so the two concentric rectangles read as a redundant double frame. Tinting the shell with surface-2 and dropping its border lets the white inner cards visibly 'sit inside' the run without competing strokes. Bumped the 'Show earlier steps' toggle hover to surface-3 so it still pops against the new surface-2 shell. Made-with: Cursor --- .../src/components/chat/ContentBlocks.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx index 0a6afc89..9cdd2498 100644 --- a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx +++ b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx @@ -603,7 +603,16 @@ function AgentRunBlock({ return (
    {earlier.length > 0 ? (
    @@ -613,7 +622,9 @@ function AgentRunBlock({ 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:bg-surface-2 hover:text-text-primary", + // 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", )} > From 5462203ad196b6b8a2a1471e3dd09b5540dff0e2 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:47:12 +0800 Subject: [PATCH 26/42] style(slark): dashed ring for pending progress steps (Cursor style) A solid outlined circle for pending/not-yet-started steps can read as 'selectable' or 'actionable'. A dashed ring (as Cursor uses) more clearly communicates 'placeholder for a future step' and visually pairs with the strikethrough + muted label already applied to unreached items. stroke-dasharray is set inline because lucide forwards style to the root , and SVG stroke-dasharray inherits down to the child . Made-with: Cursor --- .../renderer/src/components/chat/ContentBlocks.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx index 9cdd2498..749b576c 100644 --- a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx +++ b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx @@ -510,9 +510,18 @@ function ProgressBlock({ /> )} {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 , and + * SVG stroke-dasharray inherits down to the child . + */ )} From 22a87ad03844cd2e3ad925e46697abc5b10bcdaa Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:52:45 +0800 Subject: [PATCH 27/42] refactor(slark): collapse create-channel dialog to single English step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-step flow (details → add members) always defaulted to "select everyone" and the member picker screen was redundant with the separate AddMembersDialog reachable from a channel. Collapsing to one step removes a click, a progress bar, and a whole screen of UI the user mostly clicked through. On create we now seed membership with all workspace users + all agents (same default as before); owners can prune from the channel members panel afterwards. Copy is hardcoded English — this product surface is English-only, and the tokenised subtitle ("Step 1 of 2 — channel details") was the noisiest side of the old dialog. Made-with: Cursor --- .../components/chat/CreateChannelDialog.tsx | 363 ++++-------------- 1 file changed, 67 insertions(+), 296 deletions(-) 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}
    -
    - ); -} From 93573bb2eb1217465a4319e0a544c4b67ce7e81d Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 11:56:07 +0800 Subject: [PATCH 28/42] fix(ui-web): dialog description defaults to text-sm, not text-base DialogDescription was rendering at text-base (13px per our token scale) which reads chunky for a secondary supporting line, especially with CJK. The spec puts dialog descriptions (and general hint / supporting text) at text-sm (12px), which also matches shadcn's upstream default. Applying it at the primitive so every dialog benefits instead of each caller overriding className. Made-with: Cursor --- packages/ui-web/src/primitives/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-web/src/primitives/dialog.tsx b/packages/ui-web/src/primitives/dialog.tsx index a543adca..a1bde28a 100644 --- a/packages/ui-web/src/primitives/dialog.tsx +++ b/packages/ui-web/src/primitives/dialog.tsx @@ -168,7 +168,7 @@ const DialogDescription = React.forwardRef< )); From 0e7cf0adc2299e04ce3617a1a23b0487c42ad4f8 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 12:00:54 +0800 Subject: [PATCH 29/42] style: dialog-title leading-tight + delete-channel confirm in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes surfaced by the delete-channel confirm screenshot: 1. ui-web DialogTitle used leading-none, which on CJK glyphs cropped against the description underneath (no natural descender space). leading-tight (1.25) gives the title a few px of breathing room so the title/description gap reads correctly under all scripts while still feeling compact enough for Latin. 2. Slark ChatSidebar's channel-delete ConfirmDialog was still pulling copy through i18n. Matches the CreateChannelDialog treatment — this product surface is English-only, so hardcode the strings here too instead of having English copy bounce through the zh locale. Made-with: Cursor --- .claude/skills/roast-skill | 1 + .cursor/hooks.json | 10 + .cursor/hooks/say-complete.sh | 10 + .../src/components/chat/ChatSidebar.tsx | 12 +- competitive-analysis.md | 345 ++++++++++++++++++ packages/ui-web/src/primitives/dialog.tsx | 2 +- 6 files changed, 371 insertions(+), 9 deletions(-) create mode 160000 .claude/skills/roast-skill create mode 100644 .cursor/hooks.json create mode 100755 .cursor/hooks/say-complete.sh create mode 100644 competitive-analysis.md diff --git a/.claude/skills/roast-skill b/.claude/skills/roast-skill new file mode 160000 index 00000000..f2302d23 --- /dev/null +++ b/.claude/skills/roast-skill @@ -0,0 +1 @@ +Subproject commit f2302d2371fbaf16adb46363a89f9f549157693d diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000..ccbb5227 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "afterAgentResponse": [ + { + "command": ".cursor/hooks/say-complete.sh" + } + ] + } +} diff --git a/.cursor/hooks/say-complete.sh b/.cursor/hooks/say-complete.sh new file mode 100755 index 00000000..e613a279 --- /dev/null +++ b/.cursor/hooks/say-complete.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Read and discard hook payload from stdin. +cat >/dev/null + +if command -v say >/dev/null 2>&1; then + say "已完成" >/dev/null 2>&1 & +fi + +printf '{}\n' diff --git a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx index 9e498cfc..83febe33 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatSidebar.tsx @@ -270,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/competitive-analysis.md b/competitive-analysis.md new file mode 100644 index 00000000..f602907b --- /dev/null +++ b/competitive-analysis.md @@ -0,0 +1,345 @@ +# Nexu 竞品分析报告 + +> **目标受众**:开发团队 +> **产品定位**:Nexu — AI 协作平台 +> **报告日期**:2026-04-15 + +--- + +## 1. 概述 + +Nexu 是一个 AI 协作平台,旨在帮助开发团队通过 AI Agent 提升协作效率。本报告选取了 6 个与 Nexu 发展方向相似的产品进行对比分析,识别可借鉴的功能设计与产品策略,为 Nexu 的迭代方向提供参考。 + +--- + +## 2. 竞品概览 + +| 竞品 | 定位 | 开源 | GitHub Stars | 定价模式 | 产品页面 | +|------|------|------|-------------|---------|---------| +| **Paperclip** | AI Agent 编排平台(零人力公司) | ✅ MIT | 46,700+ | 免费自托管 | [官网](https://paperclip.ing/) · [GitHub](https://github.com/paperclipai/paperclip) | +| **Multica** | AI 原生项目管理(人+Agent 团队) | ✅ 开源 | 4,000+ | 免费自托管 / 云托管 | [官网](https://multica.ai/) · [GitHub](https://github.com/multica-ai/multica) | +| **Paseo** | 跨设备 AI coding agent 编排 | ✅ AGPL-3.0 | — | 免费自托管 | [官网](https://paseo.sh/) · [GitHub](https://github.com/getpaseo/paseo) | +| **Cursor** | AI 代码编辑器 | ❌ | — | 免费 / $20-$200/月 | [官网](https://cursor.com/) · [定价](https://cursor.com/pricing) | +| **飞书话题群** | 团队协作与知识沉淀 | ❌ | — | 飞书套件内置 | [话题群指南](https://www.feishu.cn/hc/zh-CN/articles/360049068007) | +| **AgentsRoom** | 多 Agent 桌面管理 IDE | ❌ | — | 免费 3 项目 / Pro | [官网](https://agentsroom.dev/) | + +--- + +## 3. 六维度对比分析 + +### 3.1 AI 能力深度 + +| 竞品 | 模型支持 | 上下文能力 | 自主性 | +|------|---------|-----------|--------| +| **Paperclip** | 自带 Agent(BYO Agent) | Agent 跨心跳恢复上下文,不重启 | 高 — Agent 自主领取任务、执行、汇报 | +| **Multica** | Claude Code, Codex, OpenClaw, OpenCode | 任务队列保持上下文 | 高 — Agent 主动认领任务、报告阻塞 | +| **Paseo** | Claude Code, Codex, OpenCode | Git worktree 隔离分支上下文 | 中 — 需用户发起,Agent 在分支内自主执行 | +| **Cursor** | Claude, GPT, Gemini 多模型切换 | 全代码库索引 | 中高 — Agent 模式可自主执行多步任务 | +| **飞书话题群** | 飞书 AI 助手(有限) | 话题内聚合上下文 | 低 — 以人为主导 | +| **AgentsRoom** | Claude, Codex, OpenCode, Gemini CLI, Aider | 每个 Agent 独立终端+系统提示 | 中 — 需用户分配角色,Agent 自主执行 | + +**对 Nexu 的启示**: +- Paperclip 的**跨心跳上下文恢复**值得借鉴,避免 Agent 重启丢失进度 +- Cursor 的**全代码库索引**能力是开发者体验的关键差异点 +- Multica 的 **Agent 自主认领任务+报告阻塞**模式在团队协作中非常实用 + +### 3.2 协作工作流 + +| 竞品 | 协作模式 | 任务管理 | 治理与审批 | +|------|---------|---------|-----------| +| **Paperclip** | 组织架构建模(部门、角色、目标) | Ticket 系统,Agent 通过 Ticket 通讯 | 审批门控 + 配置版本化 + 回滚 | +| **Multica** | 人+Agent 统一工作区,Activity Feed | 任务队列 + 状态追踪 + 看板 | 自定义审批流 | +| **Paseo** | 跨设备远程协作 | 分支导向的任务隔离 | 无内置治理 | +| **Cursor** | 单人为主,Team 计划支持共享 | 无内置任务管理 | 无 | +| **飞书话题群** | 话题订阅 + 线程式讨论 | 无(需配合飞书其他模块) | 群管理权限 | +| **AgentsRoom** | 多 Agent 并行,角色分工 | 项目级 Prompt 管理 | 无内置治理 | + +**对 Nexu 的启示**: +- Paperclip 的**治理模型**(审批门控 + 配置回滚)是企业级产品的必备能力 +- Multica 的**人与 Agent 在同一工作流中并存**是最自然的协作模式 +- 飞书话题群的**订阅机制**可有效降低信息噪音,值得在 AI 协作场景中复用 + +### 3.3 开发者体验(DX) + +| 竞品 | 安装/部署 | 扩展性 | 文档质量 | +|------|----------|--------|---------| +| **Paperclip** | 交互式安装引导,内嵌数据库或 Postgres | 自定义 Agent + 多公司支持 | 良好(GitHub README + 社区) | +| **Multica** | Docker Compose / K8s / 云托管 | 自定义 Agent 后端 + API 开放 | 良好 | +| **Paseo** | CLI daemon + 客户端自动连接 | 多 Provider 可扩展 | 中等 | +| **Cursor** | 下载即用 | 插件生态(VS Code 兼容) | 优秀(官方文档 + 大量社区内容) | +| **飞书话题群** | 飞书内置,零部署 | 飞书开放平台 Bot API | 优秀 | +| **AgentsRoom** | macOS App 下载安装 | 自定义角色 + Prompt 模板 | 中等 | + +**对 Nexu 的启示**: +- **零配置上手**是关键 — Cursor 和飞书在这点上遥遥领先 +- Paperclip 的交互式安装引导是自托管产品的好实践 +- 开放 API 和自定义 Agent 后端是开发者社区增长的基础 + +### 3.4 知识管理与沉淀 + +| 竞品 | 知识沉淀方式 | 可复用性 | +|------|------------|---------| +| **Paperclip** | 全链路追踪(指令、响应、工具调用、决策) | Ticket 历史可回溯 | +| **Multica** | 技能库(Skill Library)持续积累 | 技能跨任务复用,越用越强 | +| **Paseo** | Git 分支历史 | 代码级沉淀 | +| **Cursor** | 对话历史 + 代码库上下文 | 有限,对话不易结构化复用 | +| **飞书话题群** | 话题即知识单元,可搜索 | 话题可收藏、可转发 | +| **AgentsRoom** | Prompt 模板库(按项目、按文件夹、Git 提交) | Prompt 跨项目复用 | + +**对 Nexu 的启示**: +- Multica 的**技能库**是最有竞争力的沉淀模式 — 让 Agent 的能力随使用积累 +- Paperclip 的**全链路追踪**对调试和审计至关重要 +- 飞书的**话题即知识单元**概念可以应用到 AI 协作场景中 + +### 3.5 集成能力 + +| 竞品 | 工具链集成 | Agent 生态 | +|------|-----------|-----------| +| **Paperclip** | 自定义 Agent,支持任意 LLM | OpenClaw 生态 | +| **Multica** | Claude Code, Codex, OpenClaw, OpenCode | 自动检测已安装 CLI | +| **Paseo** | Claude Code, Codex, OpenCode | 多 Provider 统一接口 | +| **Cursor** | VS Code 插件生态 + MCP 协议 | 内置多模型 | +| **飞书话题群** | 飞书全家桶(文档、日历、审批等) | 飞书 Bot 开放平台 | +| **AgentsRoom** | Claude, Codex, OpenCode, Gemini CLI, Aider | 自定义角色 | + +**对 Nexu 的启示**: +- 支持**主流 Agent CLI 自动检测**(如 Multica)降低接入门槛 +- Cursor 的 **MCP 协议**是 AI 工具集成的新标准,值得关注 +- 与**现有 IM 工具**(飞书/Slack)的集成是团队采纳的关键 + +### 3.6 产品成熟度 + +| 竞品 | 阶段 | 社区 | 商业化 | +|------|------|------|--------| +| **Paperclip** | 快速增长期(46K+ stars) | 活跃开源社区 | 尚未商业化 | +| **Multica** | 早期增长(4K+ stars) | 成长中 | 云托管版本 | +| **Paseo** | 早期 | 小规模 | 无 | +| **Cursor** | 成熟期($2B ARR,1M+ 付费用户) | 大规模用户社区 | 成熟订阅制 | +| **飞书话题群** | 成熟 | 企业用户基础 | 飞书套件定价 | +| **AgentsRoom** | 早期 | 小规模 | Freemium | + +--- + +## 4. 各竞品详细分析 + +### 4.1 Paperclip — [官网](https://paperclip.ing/) · [GitHub](https://github.com/paperclipai/paperclip) + +**产品简介**:Paperclip 是一个开源的 AI Agent 编排平台,目标是让用户能够构建和运行"零人力公司"。它提供组织架构建模、预算管理、治理门控等企业级功能。 + +**核心亮点**: +- **组织架构即代码**:部门、角色、目标、预算全部可配置,Agent 在组织结构中运作 +- **原子性任务调度**:任务领取和预算扣减是原子操作,杜绝重复工作和超支 +- **全链路可追踪**:每条指令、响应、工具调用和决策都有完整记录 +- **跨心跳上下文恢复**:Agent 断线后可恢复同一任务上下文,而非重新开始 +- **多公司隔离**:单实例支持多组织,数据完全隔离 + +**不足**: +- 定位偏"自动化公司",对人机协作场景支持较弱 +- 无云托管版本,部署门槛较高 +- 对非技术用户不友好 + +**对 Nexu 的借鉴**: +- 治理门控 + 配置版本化 + 回滚机制 +- 原子性任务调度防止资源浪费 +- 全链路追踪能力 + +--- + +### 4.2 Multica — [官网](https://multica.ai/) · [GitHub](https://github.com/multica-ai/multica) + +**产品简介**:Multica 是一个 AI 原生项目管理平台,核心理念是将 coding agent 视为真正的团队成员。Agent 有自己的 profile,可以认领任务、报告状态、评论和更新进度。 + +**核心亮点**: +- **Agent 即队友**:Agent 出现在分配选择器中,分配任务给 Agent 和分配给同事的体验完全一致 +- **自主执行**:Agent 不只是响应 Prompt,而是主动认领任务、报告阻塞、留评论、更新状态 +- **技能库**:可复用的 Agent 能力随时间积累,越用越强 +- **统一活动流**:人和 Agent 的工作在同一个 Feed 中展示 +- **完全可审计**:每一行代码可审计,Agent 决策路径透明 + +**不足**: +- 社区规模较小(4K stars),生态尚未成熟 +- 主要面向 coding agent,非编码场景覆盖有限 +- 文档和教程仍在完善中 + +**对 Nexu 的借鉴**: +- **人+Agent 统一工作区**是最自然的协作形态 +- **技能库**是强大的飞轮效应——使用越多,Agent 越强 +- Agent 的 Profile 和状态系统设计 + +--- + +### 4.3 Paseo — [官网](https://paseo.sh/) · [GitHub](https://github.com/getpaseo/paseo) + +**产品简介**:Paseo 是一个开源的跨设备 AI coding agent 编排平台,支持从手机、桌面和 CLI 远程管理 Agent。基于 daemon 架构,本地运行,隐私优先。 + +**核心亮点**: +- **跨设备访问**:手机上也能管理和监控 Agent 的工作进度 +- **Git Worktree 原生支持**:并行 Agent 任务在隔离分支中运行,安全性高 +- **多 Provider 统一接口**:Claude Code, Codex, OpenCode 在同一界面管理 +- **语音输入**:设备端语音处理,解放双手 +- **E2E 加密中继**:远程访问时保证安全 +- **零遥测、零追踪**:隐私优先的设计哲学 + +**不足**: +- 无内置任务管理或项目管理功能 +- 协作能力有限,偏向个人使用 +- 社区和生态较小 + +**对 Nexu 的借鉴**: +- **跨设备体验**是差异化亮点——移动端监控 Agent 工作是刚需 +- **Git Worktree 隔离**是多 Agent 并行的安全保障 +- 隐私优先的设计赢得开发者信任 + +--- + +### 4.4 Cursor — [官网](https://cursor.com/) · [定价](https://cursor.com/pricing) + +**产品简介**:Cursor 是基于 VS Code 构建的 AI 代码编辑器,将 AI 深度集成到编辑体验的每个环节。目前是 AI 编码工具领域的市场领导者,拥有 100 万+付费用户和 $2B ARR。 + +**核心亮点**: +- **全代码库索引**:AI 基于完整的项目结构和文件依赖关系回答问题 +- **多文件编辑**(Composer):跨 20+ 文件的重构、重命名、API 更新一次完成 +- **Agent 模式**:可自主执行多步任务,显著提升吞吐量 +- **多模型灵活切换**:Claude 做推理、GPT 做生成、Gemini 做快速迭代 +- **VS Code 兼容生态**:无缝使用已有插件 +- **成熟的定价体系**:从免费到 $200/月,覆盖所有用户层级 + +**不足**: +- 偏向个人开发者,团队协作能力较弱 +- Agent 模式仍在编辑器范围内,无法管理外部工作流 +- 闭源,无法自定义核心行为 +- 高级功能定价不低 + +**对 Nexu 的借鉴**: +- **全代码库索引**是 AI 理解项目的基础能力 +- **多模型切换**让用户为不同任务选择最优模型 +- 阶梯式定价策略和用户增长路径 + +--- + +### 4.5 飞书话题群 — [话题群指南](https://www.feishu.cn/hc/zh-CN/articles/360049068007) · [使用话题群](https://www.feishu.cn/hc/zh-CN/articles/360049067735) + +**产品简介**:飞书话题群是飞书即时通讯中的一种群组模式,所有内容以话题形式聚合展示,成员可发布、回复和订阅感兴趣的话题。 + +**核心亮点**: +- **话题即知识单元**:每个话题自成一个讨论线程,上下文天然聚合 +- **订阅机制降噪**:只关注感兴趣的话题,不关心的讨论不会发通知 +- **零学习成本**:飞书用户无需额外学习,聊天即协作 +- **与飞书生态深度打通**:文档、日历、审批、Bot 等模块无缝联动 +- **知识可搜索、可收藏**:话题沉淀为可检索的知识资产 + +**不足**: +- 无 AI Agent 能力(飞书 AI 助手功能有限) +- 不面向开发场景,无代码相关功能 +- 话题量大时信息组织仍有挑战 +- 封闭生态,依赖飞书平台 + +**对 Nexu 的借鉴**: +- **话题+订阅**模式是管理 AI 协作信息流的绝佳方案 +- **零学习成本**的交互设计是产品采纳的关键 +- 将 AI 对话/任务结构化为可搜索的"话题"知识单元 + +--- + +### 4.6 AgentsRoom — [官网](https://agentsroom.dev/) · [Agent 角色](https://agentsroom.dev/agents) + +**产品简介**:AgentsRoom 是一个 macOS 原生桌面应用,为 AI coding agent 提供可视化管理界面。支持 13 种预设角色,每个 Agent 有独立终端和系统提示。 + +**核心亮点**: +- **13 种专业角色**:架构师、全栈、前端、后端、移动端、DevOps、QA、安全、PM、营销、Git 专家、SEO、本地化 +- **可视化状态监控**:颜色编码状态指示(思考中/完成/等待输入) +- **独立终端**:每个 Agent 10K 行回滚、语法高亮、可点击链接 +- **Prompt 模板库**:按项目/文件夹组织,可 Git 提交 +- **移动端通知**:Agent 完成或卡住时手机推送 +- **多 Agent 支持**:Claude, Codex, OpenCode, Gemini CLI, Aider + +**不足**: +- 仅支持 macOS +- 无内置协作或项目管理功能 +- 偏向 Agent 管理 UI,缺乏深层工作流编排 +- 社区较小 + +**对 Nexu 的借鉴**: +- **角色系统+专属 System Prompt** 是组织多 Agent 的直观方式 +- **状态可视化**(思考中/完成/等待)是多 Agent 场景的基本需求 +- **Prompt 模板管理**帮助团队沉淀最佳实践 + +--- + +## 5. 对比矩阵总结 + +评分标准:⭐ 弱 / ⭐⭐ 一般 / ⭐⭐⭐ 良好 / ⭐⭐⭐⭐ 优秀 / ⭐⭐⭐⭐⭐ 卓越 + +| 维度 | Paperclip | Multica | Paseo | Cursor | 飞书话题群 | AgentsRoom | +|------|-----------|---------|-------|--------|-----------|------------| +| AI 能力深度 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐ | +| 协作工作流 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | +| 开发者体验 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | +| 知识沉淀 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | +| 集成能力 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | +| 产品成熟度 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | + +--- + +## 6. Nexu 改进建议 + +### 6.1 Quick Wins(1-2 周可落地) + +| 优先级 | 建议 | 参考竞品 | 预期效果 | +|--------|------|---------|---------| +| P0 | **多 Agent 状态可视化**:实时显示每个 Agent 的状态(思考中/执行中/完成/阻塞) | AgentsRoom | 降低多 Agent 管理的认知负担 | +| P0 | **话题式信息组织**:将 AI 对话/任务按话题聚合,支持订阅和过滤 | 飞书话题群 | 减少信息噪音,提升信息获取效率 | +| P1 | **Prompt 模板库**:团队共享的 Prompt 模板,按项目组织 | AgentsRoom | 沉淀最佳实践,降低新人上手成本 | + +### 6.2 中期规划(1-3 个月) + +| 优先级 | 建议 | 参考竞品 | 预期效果 | +|--------|------|---------|---------| +| P0 | **人+Agent 统一工作区**:Agent 作为团队成员出现在任务系统中,可分配/追踪 | Multica | 最自然的人机协作形态 | +| P0 | **技能库系统**:Agent 的能力模块化、可复用、可积累 | Multica | 构建飞轮效应,越用越强 | +| P1 | **治理与审批门控**:关键操作需审批,配置变更可回滚 | Paperclip | 满足企业级安全和合规需求 | +| P1 | **全链路追踪**:记录 Agent 的每一步决策和操作 | Paperclip | 调试、审计、问责 | +| P2 | **Git Worktree 隔离**:多 Agent 并行任务在隔离分支中运行 | Paseo | 提升多 Agent 并行的安全性 | + +### 6.3 长期战略(3-6 个月) + +| 优先级 | 建议 | 参考竞品 | 预期效果 | +|--------|------|---------|---------| +| P0 | **全代码库索引 + 多模型支持**:深度理解项目上下文,灵活切换最优模型 | Cursor | 核心 AI 能力的质变 | +| P1 | **跨设备体验**:移动端监控和管理 Agent 工作 | Paseo | 满足随时随地查看进度的需求 | +| P1 | **开放 Agent 生态**:支持自定义 Agent 后端 + 主流 CLI 自动检测 | Multica | 扩大开发者社区和生态 | +| P2 | **MCP 协议支持**:对接 AI 工具集成新标准 | Cursor | 面向未来的集成能力 | + +--- + +## 7. 总结 + +### 核心发现 + +1. **协作是主战场**:Paperclip 和 Multica 代表了两种协作范式——"Agent 自治组织"和"人+Agent 团队"。对于 Nexu 的 AI 协作平台定位,Multica 的"Agent 即队友"模式更贴合实际团队需求。 + +2. **知识沉淀是护城河**:Multica 的技能库和飞书的话题模式都展示了结构化沉淀的价值。AI 协作产生的知识如果不能有效沉淀和复用,就是一次性消耗。 + +3. **开发者体验决定采纳速度**:Cursor 的成功证明了"零配置 + 深度集成"的威力。Nexu 需要在上手体验上做到极致简单。 + +4. **治理能力是企业级的门票**:Paperclip 的审批门控、配置回滚、全链路追踪是面向企业客户的必备能力。 + +5. **跨设备和移动端是差异化机会**:Paseo 开辟了"手机管 Agent"的场景,这在其他竞品中尚未普及。 + +### Nexu 的差异化路径建议 + +Nexu 应聚焦于成为**"开发团队的 AI 协作中枢"**——结合 Multica 的人+Agent 协作模式、Paperclip 的企业级治理、飞书的信息组织方式、以及 Cursor 级别的开发者体验。关键是避免成为又一个"Agent 管理 UI",而是真正解决团队协作中的信息流转、知识沉淀和决策追踪问题。 + +--- + +## Sources + +- [Paperclip 官网](https://paperclip.ing/) +- [Paperclip GitHub](https://github.com/paperclipai/paperclip) +- [Multica 官网](https://multica.ai/) +- [Multica GitHub](https://github.com/multica-ai/multica) +- [Paseo GitHub](https://github.com/getpaseo/paseo) +- [Cursor 官网定价](https://cursor.com/pricing) +- [飞书话题群指南](https://www.feishu.cn/hc/zh-CN/articles/360049068007) +- [AgentsRoom](https://agentsroom.dev/) +- [AgentsRoom vs Paseo](https://agentsroom.dev/compare/paseo) diff --git a/packages/ui-web/src/primitives/dialog.tsx b/packages/ui-web/src/primitives/dialog.tsx index a1bde28a..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< )); From 160bd80f83ce5bccb7d3713e61d79af4f37ef80a Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 12:01:11 +0800 Subject: [PATCH 30/42] chore: untrack .claude/.cursor/competitive-analysis.md (accidentally added) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit 0e7cf0a slipped in four unrelated files that should have stayed untracked — a .claude skill submodule, two local .cursor hook artefacts, and a personal analysis markdown. None of them belong to the slark/ui-web surface. Removing from the index while keeping the working-tree copies. Made-with: Cursor --- .claude/skills/roast-skill | 1 - .cursor/hooks.json | 10 - .cursor/hooks/say-complete.sh | 10 - competitive-analysis.md | 345 ---------------------------------- 4 files changed, 366 deletions(-) delete mode 160000 .claude/skills/roast-skill delete mode 100644 .cursor/hooks.json delete mode 100755 .cursor/hooks/say-complete.sh delete mode 100644 competitive-analysis.md diff --git a/.claude/skills/roast-skill b/.claude/skills/roast-skill deleted file mode 160000 index f2302d23..00000000 --- a/.claude/skills/roast-skill +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f2302d2371fbaf16adb46363a89f9f549157693d diff --git a/.cursor/hooks.json b/.cursor/hooks.json deleted file mode 100644 index ccbb5227..00000000 --- a/.cursor/hooks.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "hooks": { - "afterAgentResponse": [ - { - "command": ".cursor/hooks/say-complete.sh" - } - ] - } -} diff --git a/.cursor/hooks/say-complete.sh b/.cursor/hooks/say-complete.sh deleted file mode 100755 index e613a279..00000000 --- a/.cursor/hooks/say-complete.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# Read and discard hook payload from stdin. -cat >/dev/null - -if command -v say >/dev/null 2>&1; then - say "已完成" >/dev/null 2>&1 & -fi - -printf '{}\n' diff --git a/competitive-analysis.md b/competitive-analysis.md deleted file mode 100644 index f602907b..00000000 --- a/competitive-analysis.md +++ /dev/null @@ -1,345 +0,0 @@ -# Nexu 竞品分析报告 - -> **目标受众**:开发团队 -> **产品定位**:Nexu — AI 协作平台 -> **报告日期**:2026-04-15 - ---- - -## 1. 概述 - -Nexu 是一个 AI 协作平台,旨在帮助开发团队通过 AI Agent 提升协作效率。本报告选取了 6 个与 Nexu 发展方向相似的产品进行对比分析,识别可借鉴的功能设计与产品策略,为 Nexu 的迭代方向提供参考。 - ---- - -## 2. 竞品概览 - -| 竞品 | 定位 | 开源 | GitHub Stars | 定价模式 | 产品页面 | -|------|------|------|-------------|---------|---------| -| **Paperclip** | AI Agent 编排平台(零人力公司) | ✅ MIT | 46,700+ | 免费自托管 | [官网](https://paperclip.ing/) · [GitHub](https://github.com/paperclipai/paperclip) | -| **Multica** | AI 原生项目管理(人+Agent 团队) | ✅ 开源 | 4,000+ | 免费自托管 / 云托管 | [官网](https://multica.ai/) · [GitHub](https://github.com/multica-ai/multica) | -| **Paseo** | 跨设备 AI coding agent 编排 | ✅ AGPL-3.0 | — | 免费自托管 | [官网](https://paseo.sh/) · [GitHub](https://github.com/getpaseo/paseo) | -| **Cursor** | AI 代码编辑器 | ❌ | — | 免费 / $20-$200/月 | [官网](https://cursor.com/) · [定价](https://cursor.com/pricing) | -| **飞书话题群** | 团队协作与知识沉淀 | ❌ | — | 飞书套件内置 | [话题群指南](https://www.feishu.cn/hc/zh-CN/articles/360049068007) | -| **AgentsRoom** | 多 Agent 桌面管理 IDE | ❌ | — | 免费 3 项目 / Pro | [官网](https://agentsroom.dev/) | - ---- - -## 3. 六维度对比分析 - -### 3.1 AI 能力深度 - -| 竞品 | 模型支持 | 上下文能力 | 自主性 | -|------|---------|-----------|--------| -| **Paperclip** | 自带 Agent(BYO Agent) | Agent 跨心跳恢复上下文,不重启 | 高 — Agent 自主领取任务、执行、汇报 | -| **Multica** | Claude Code, Codex, OpenClaw, OpenCode | 任务队列保持上下文 | 高 — Agent 主动认领任务、报告阻塞 | -| **Paseo** | Claude Code, Codex, OpenCode | Git worktree 隔离分支上下文 | 中 — 需用户发起,Agent 在分支内自主执行 | -| **Cursor** | Claude, GPT, Gemini 多模型切换 | 全代码库索引 | 中高 — Agent 模式可自主执行多步任务 | -| **飞书话题群** | 飞书 AI 助手(有限) | 话题内聚合上下文 | 低 — 以人为主导 | -| **AgentsRoom** | Claude, Codex, OpenCode, Gemini CLI, Aider | 每个 Agent 独立终端+系统提示 | 中 — 需用户分配角色,Agent 自主执行 | - -**对 Nexu 的启示**: -- Paperclip 的**跨心跳上下文恢复**值得借鉴,避免 Agent 重启丢失进度 -- Cursor 的**全代码库索引**能力是开发者体验的关键差异点 -- Multica 的 **Agent 自主认领任务+报告阻塞**模式在团队协作中非常实用 - -### 3.2 协作工作流 - -| 竞品 | 协作模式 | 任务管理 | 治理与审批 | -|------|---------|---------|-----------| -| **Paperclip** | 组织架构建模(部门、角色、目标) | Ticket 系统,Agent 通过 Ticket 通讯 | 审批门控 + 配置版本化 + 回滚 | -| **Multica** | 人+Agent 统一工作区,Activity Feed | 任务队列 + 状态追踪 + 看板 | 自定义审批流 | -| **Paseo** | 跨设备远程协作 | 分支导向的任务隔离 | 无内置治理 | -| **Cursor** | 单人为主,Team 计划支持共享 | 无内置任务管理 | 无 | -| **飞书话题群** | 话题订阅 + 线程式讨论 | 无(需配合飞书其他模块) | 群管理权限 | -| **AgentsRoom** | 多 Agent 并行,角色分工 | 项目级 Prompt 管理 | 无内置治理 | - -**对 Nexu 的启示**: -- Paperclip 的**治理模型**(审批门控 + 配置回滚)是企业级产品的必备能力 -- Multica 的**人与 Agent 在同一工作流中并存**是最自然的协作模式 -- 飞书话题群的**订阅机制**可有效降低信息噪音,值得在 AI 协作场景中复用 - -### 3.3 开发者体验(DX) - -| 竞品 | 安装/部署 | 扩展性 | 文档质量 | -|------|----------|--------|---------| -| **Paperclip** | 交互式安装引导,内嵌数据库或 Postgres | 自定义 Agent + 多公司支持 | 良好(GitHub README + 社区) | -| **Multica** | Docker Compose / K8s / 云托管 | 自定义 Agent 后端 + API 开放 | 良好 | -| **Paseo** | CLI daemon + 客户端自动连接 | 多 Provider 可扩展 | 中等 | -| **Cursor** | 下载即用 | 插件生态(VS Code 兼容) | 优秀(官方文档 + 大量社区内容) | -| **飞书话题群** | 飞书内置,零部署 | 飞书开放平台 Bot API | 优秀 | -| **AgentsRoom** | macOS App 下载安装 | 自定义角色 + Prompt 模板 | 中等 | - -**对 Nexu 的启示**: -- **零配置上手**是关键 — Cursor 和飞书在这点上遥遥领先 -- Paperclip 的交互式安装引导是自托管产品的好实践 -- 开放 API 和自定义 Agent 后端是开发者社区增长的基础 - -### 3.4 知识管理与沉淀 - -| 竞品 | 知识沉淀方式 | 可复用性 | -|------|------------|---------| -| **Paperclip** | 全链路追踪(指令、响应、工具调用、决策) | Ticket 历史可回溯 | -| **Multica** | 技能库(Skill Library)持续积累 | 技能跨任务复用,越用越强 | -| **Paseo** | Git 分支历史 | 代码级沉淀 | -| **Cursor** | 对话历史 + 代码库上下文 | 有限,对话不易结构化复用 | -| **飞书话题群** | 话题即知识单元,可搜索 | 话题可收藏、可转发 | -| **AgentsRoom** | Prompt 模板库(按项目、按文件夹、Git 提交) | Prompt 跨项目复用 | - -**对 Nexu 的启示**: -- Multica 的**技能库**是最有竞争力的沉淀模式 — 让 Agent 的能力随使用积累 -- Paperclip 的**全链路追踪**对调试和审计至关重要 -- 飞书的**话题即知识单元**概念可以应用到 AI 协作场景中 - -### 3.5 集成能力 - -| 竞品 | 工具链集成 | Agent 生态 | -|------|-----------|-----------| -| **Paperclip** | 自定义 Agent,支持任意 LLM | OpenClaw 生态 | -| **Multica** | Claude Code, Codex, OpenClaw, OpenCode | 自动检测已安装 CLI | -| **Paseo** | Claude Code, Codex, OpenCode | 多 Provider 统一接口 | -| **Cursor** | VS Code 插件生态 + MCP 协议 | 内置多模型 | -| **飞书话题群** | 飞书全家桶(文档、日历、审批等) | 飞书 Bot 开放平台 | -| **AgentsRoom** | Claude, Codex, OpenCode, Gemini CLI, Aider | 自定义角色 | - -**对 Nexu 的启示**: -- 支持**主流 Agent CLI 自动检测**(如 Multica)降低接入门槛 -- Cursor 的 **MCP 协议**是 AI 工具集成的新标准,值得关注 -- 与**现有 IM 工具**(飞书/Slack)的集成是团队采纳的关键 - -### 3.6 产品成熟度 - -| 竞品 | 阶段 | 社区 | 商业化 | -|------|------|------|--------| -| **Paperclip** | 快速增长期(46K+ stars) | 活跃开源社区 | 尚未商业化 | -| **Multica** | 早期增长(4K+ stars) | 成长中 | 云托管版本 | -| **Paseo** | 早期 | 小规模 | 无 | -| **Cursor** | 成熟期($2B ARR,1M+ 付费用户) | 大规模用户社区 | 成熟订阅制 | -| **飞书话题群** | 成熟 | 企业用户基础 | 飞书套件定价 | -| **AgentsRoom** | 早期 | 小规模 | Freemium | - ---- - -## 4. 各竞品详细分析 - -### 4.1 Paperclip — [官网](https://paperclip.ing/) · [GitHub](https://github.com/paperclipai/paperclip) - -**产品简介**:Paperclip 是一个开源的 AI Agent 编排平台,目标是让用户能够构建和运行"零人力公司"。它提供组织架构建模、预算管理、治理门控等企业级功能。 - -**核心亮点**: -- **组织架构即代码**:部门、角色、目标、预算全部可配置,Agent 在组织结构中运作 -- **原子性任务调度**:任务领取和预算扣减是原子操作,杜绝重复工作和超支 -- **全链路可追踪**:每条指令、响应、工具调用和决策都有完整记录 -- **跨心跳上下文恢复**:Agent 断线后可恢复同一任务上下文,而非重新开始 -- **多公司隔离**:单实例支持多组织,数据完全隔离 - -**不足**: -- 定位偏"自动化公司",对人机协作场景支持较弱 -- 无云托管版本,部署门槛较高 -- 对非技术用户不友好 - -**对 Nexu 的借鉴**: -- 治理门控 + 配置版本化 + 回滚机制 -- 原子性任务调度防止资源浪费 -- 全链路追踪能力 - ---- - -### 4.2 Multica — [官网](https://multica.ai/) · [GitHub](https://github.com/multica-ai/multica) - -**产品简介**:Multica 是一个 AI 原生项目管理平台,核心理念是将 coding agent 视为真正的团队成员。Agent 有自己的 profile,可以认领任务、报告状态、评论和更新进度。 - -**核心亮点**: -- **Agent 即队友**:Agent 出现在分配选择器中,分配任务给 Agent 和分配给同事的体验完全一致 -- **自主执行**:Agent 不只是响应 Prompt,而是主动认领任务、报告阻塞、留评论、更新状态 -- **技能库**:可复用的 Agent 能力随时间积累,越用越强 -- **统一活动流**:人和 Agent 的工作在同一个 Feed 中展示 -- **完全可审计**:每一行代码可审计,Agent 决策路径透明 - -**不足**: -- 社区规模较小(4K stars),生态尚未成熟 -- 主要面向 coding agent,非编码场景覆盖有限 -- 文档和教程仍在完善中 - -**对 Nexu 的借鉴**: -- **人+Agent 统一工作区**是最自然的协作形态 -- **技能库**是强大的飞轮效应——使用越多,Agent 越强 -- Agent 的 Profile 和状态系统设计 - ---- - -### 4.3 Paseo — [官网](https://paseo.sh/) · [GitHub](https://github.com/getpaseo/paseo) - -**产品简介**:Paseo 是一个开源的跨设备 AI coding agent 编排平台,支持从手机、桌面和 CLI 远程管理 Agent。基于 daemon 架构,本地运行,隐私优先。 - -**核心亮点**: -- **跨设备访问**:手机上也能管理和监控 Agent 的工作进度 -- **Git Worktree 原生支持**:并行 Agent 任务在隔离分支中运行,安全性高 -- **多 Provider 统一接口**:Claude Code, Codex, OpenCode 在同一界面管理 -- **语音输入**:设备端语音处理,解放双手 -- **E2E 加密中继**:远程访问时保证安全 -- **零遥测、零追踪**:隐私优先的设计哲学 - -**不足**: -- 无内置任务管理或项目管理功能 -- 协作能力有限,偏向个人使用 -- 社区和生态较小 - -**对 Nexu 的借鉴**: -- **跨设备体验**是差异化亮点——移动端监控 Agent 工作是刚需 -- **Git Worktree 隔离**是多 Agent 并行的安全保障 -- 隐私优先的设计赢得开发者信任 - ---- - -### 4.4 Cursor — [官网](https://cursor.com/) · [定价](https://cursor.com/pricing) - -**产品简介**:Cursor 是基于 VS Code 构建的 AI 代码编辑器,将 AI 深度集成到编辑体验的每个环节。目前是 AI 编码工具领域的市场领导者,拥有 100 万+付费用户和 $2B ARR。 - -**核心亮点**: -- **全代码库索引**:AI 基于完整的项目结构和文件依赖关系回答问题 -- **多文件编辑**(Composer):跨 20+ 文件的重构、重命名、API 更新一次完成 -- **Agent 模式**:可自主执行多步任务,显著提升吞吐量 -- **多模型灵活切换**:Claude 做推理、GPT 做生成、Gemini 做快速迭代 -- **VS Code 兼容生态**:无缝使用已有插件 -- **成熟的定价体系**:从免费到 $200/月,覆盖所有用户层级 - -**不足**: -- 偏向个人开发者,团队协作能力较弱 -- Agent 模式仍在编辑器范围内,无法管理外部工作流 -- 闭源,无法自定义核心行为 -- 高级功能定价不低 - -**对 Nexu 的借鉴**: -- **全代码库索引**是 AI 理解项目的基础能力 -- **多模型切换**让用户为不同任务选择最优模型 -- 阶梯式定价策略和用户增长路径 - ---- - -### 4.5 飞书话题群 — [话题群指南](https://www.feishu.cn/hc/zh-CN/articles/360049068007) · [使用话题群](https://www.feishu.cn/hc/zh-CN/articles/360049067735) - -**产品简介**:飞书话题群是飞书即时通讯中的一种群组模式,所有内容以话题形式聚合展示,成员可发布、回复和订阅感兴趣的话题。 - -**核心亮点**: -- **话题即知识单元**:每个话题自成一个讨论线程,上下文天然聚合 -- **订阅机制降噪**:只关注感兴趣的话题,不关心的讨论不会发通知 -- **零学习成本**:飞书用户无需额外学习,聊天即协作 -- **与飞书生态深度打通**:文档、日历、审批、Bot 等模块无缝联动 -- **知识可搜索、可收藏**:话题沉淀为可检索的知识资产 - -**不足**: -- 无 AI Agent 能力(飞书 AI 助手功能有限) -- 不面向开发场景,无代码相关功能 -- 话题量大时信息组织仍有挑战 -- 封闭生态,依赖飞书平台 - -**对 Nexu 的借鉴**: -- **话题+订阅**模式是管理 AI 协作信息流的绝佳方案 -- **零学习成本**的交互设计是产品采纳的关键 -- 将 AI 对话/任务结构化为可搜索的"话题"知识单元 - ---- - -### 4.6 AgentsRoom — [官网](https://agentsroom.dev/) · [Agent 角色](https://agentsroom.dev/agents) - -**产品简介**:AgentsRoom 是一个 macOS 原生桌面应用,为 AI coding agent 提供可视化管理界面。支持 13 种预设角色,每个 Agent 有独立终端和系统提示。 - -**核心亮点**: -- **13 种专业角色**:架构师、全栈、前端、后端、移动端、DevOps、QA、安全、PM、营销、Git 专家、SEO、本地化 -- **可视化状态监控**:颜色编码状态指示(思考中/完成/等待输入) -- **独立终端**:每个 Agent 10K 行回滚、语法高亮、可点击链接 -- **Prompt 模板库**:按项目/文件夹组织,可 Git 提交 -- **移动端通知**:Agent 完成或卡住时手机推送 -- **多 Agent 支持**:Claude, Codex, OpenCode, Gemini CLI, Aider - -**不足**: -- 仅支持 macOS -- 无内置协作或项目管理功能 -- 偏向 Agent 管理 UI,缺乏深层工作流编排 -- 社区较小 - -**对 Nexu 的借鉴**: -- **角色系统+专属 System Prompt** 是组织多 Agent 的直观方式 -- **状态可视化**(思考中/完成/等待)是多 Agent 场景的基本需求 -- **Prompt 模板管理**帮助团队沉淀最佳实践 - ---- - -## 5. 对比矩阵总结 - -评分标准:⭐ 弱 / ⭐⭐ 一般 / ⭐⭐⭐ 良好 / ⭐⭐⭐⭐ 优秀 / ⭐⭐⭐⭐⭐ 卓越 - -| 维度 | Paperclip | Multica | Paseo | Cursor | 飞书话题群 | AgentsRoom | -|------|-----------|---------|-------|--------|-----------|------------| -| AI 能力深度 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐ | -| 协作工作流 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | -| 开发者体验 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | -| 知识沉淀 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | -| 集成能力 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | -| 产品成熟度 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | - ---- - -## 6. Nexu 改进建议 - -### 6.1 Quick Wins(1-2 周可落地) - -| 优先级 | 建议 | 参考竞品 | 预期效果 | -|--------|------|---------|---------| -| P0 | **多 Agent 状态可视化**:实时显示每个 Agent 的状态(思考中/执行中/完成/阻塞) | AgentsRoom | 降低多 Agent 管理的认知负担 | -| P0 | **话题式信息组织**:将 AI 对话/任务按话题聚合,支持订阅和过滤 | 飞书话题群 | 减少信息噪音,提升信息获取效率 | -| P1 | **Prompt 模板库**:团队共享的 Prompt 模板,按项目组织 | AgentsRoom | 沉淀最佳实践,降低新人上手成本 | - -### 6.2 中期规划(1-3 个月) - -| 优先级 | 建议 | 参考竞品 | 预期效果 | -|--------|------|---------|---------| -| P0 | **人+Agent 统一工作区**:Agent 作为团队成员出现在任务系统中,可分配/追踪 | Multica | 最自然的人机协作形态 | -| P0 | **技能库系统**:Agent 的能力模块化、可复用、可积累 | Multica | 构建飞轮效应,越用越强 | -| P1 | **治理与审批门控**:关键操作需审批,配置变更可回滚 | Paperclip | 满足企业级安全和合规需求 | -| P1 | **全链路追踪**:记录 Agent 的每一步决策和操作 | Paperclip | 调试、审计、问责 | -| P2 | **Git Worktree 隔离**:多 Agent 并行任务在隔离分支中运行 | Paseo | 提升多 Agent 并行的安全性 | - -### 6.3 长期战略(3-6 个月) - -| 优先级 | 建议 | 参考竞品 | 预期效果 | -|--------|------|---------|---------| -| P0 | **全代码库索引 + 多模型支持**:深度理解项目上下文,灵活切换最优模型 | Cursor | 核心 AI 能力的质变 | -| P1 | **跨设备体验**:移动端监控和管理 Agent 工作 | Paseo | 满足随时随地查看进度的需求 | -| P1 | **开放 Agent 生态**:支持自定义 Agent 后端 + 主流 CLI 自动检测 | Multica | 扩大开发者社区和生态 | -| P2 | **MCP 协议支持**:对接 AI 工具集成新标准 | Cursor | 面向未来的集成能力 | - ---- - -## 7. 总结 - -### 核心发现 - -1. **协作是主战场**:Paperclip 和 Multica 代表了两种协作范式——"Agent 自治组织"和"人+Agent 团队"。对于 Nexu 的 AI 协作平台定位,Multica 的"Agent 即队友"模式更贴合实际团队需求。 - -2. **知识沉淀是护城河**:Multica 的技能库和飞书的话题模式都展示了结构化沉淀的价值。AI 协作产生的知识如果不能有效沉淀和复用,就是一次性消耗。 - -3. **开发者体验决定采纳速度**:Cursor 的成功证明了"零配置 + 深度集成"的威力。Nexu 需要在上手体验上做到极致简单。 - -4. **治理能力是企业级的门票**:Paperclip 的审批门控、配置回滚、全链路追踪是面向企业客户的必备能力。 - -5. **跨设备和移动端是差异化机会**:Paseo 开辟了"手机管 Agent"的场景,这在其他竞品中尚未普及。 - -### Nexu 的差异化路径建议 - -Nexu 应聚焦于成为**"开发团队的 AI 协作中枢"**——结合 Multica 的人+Agent 协作模式、Paperclip 的企业级治理、飞书的信息组织方式、以及 Cursor 级别的开发者体验。关键是避免成为又一个"Agent 管理 UI",而是真正解决团队协作中的信息流转、知识沉淀和决策追踪问题。 - ---- - -## Sources - -- [Paperclip 官网](https://paperclip.ing/) -- [Paperclip GitHub](https://github.com/paperclipai/paperclip) -- [Multica 官网](https://multica.ai/) -- [Multica GitHub](https://github.com/multica-ai/multica) -- [Paseo GitHub](https://github.com/getpaseo/paseo) -- [Cursor 官网定价](https://cursor.com/pricing) -- [飞书话题群指南](https://www.feishu.cn/hc/zh-CN/articles/360049068007) -- [AgentsRoom](https://agentsroom.dev/) -- [AgentsRoom vs Paseo](https://agentsroom.dev/compare/paseo) From 828fae5779ebbc78abc9f56bd0aa52c1cd7269b4 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 12:02:57 +0800 Subject: [PATCH 31/42] style(slark): swap approval buttons to Reject | Approve (primary right) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the design spec, primary/confirm actions trail right and secondary actions sit to their left. The approval card had Approve on the left and Reject on the right — mirrored from the convention. Both keep flex-1 so the row still reads as 'pick one of two equal paths' rather than a weighted CTA. Made-with: Cursor --- .../src/components/chat/ContentBlocks.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx index 749b576c..e71d6c34 100644 --- a/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx +++ b/apps/slark/src/renderer/src/components/chat/ContentBlocks.tsx @@ -417,15 +417,14 @@ function ApprovalBlock({
    {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" && ( From 4afffa11a672d3d82b2be819ffbff0bbfa6bbc39 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 12:06:18 +0800 Subject: [PATCH 32/42] feat(slark): composer send/stop button + Cursor-style states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four polish fixes to the message composer: 1. Send button promoted from a ghost Send icon to a circular accent- filled ArrowUp — matches the Cursor composer. Three explicit states: disabled (surface-3 fill + muted arrow, no text / nothing to abort), ready (accent fill + white arrow, has text), and streaming (accent fill + white stop square). One button, one footprint, three meanings. 2. Stop works: clicking the stop square flips every in-flight reply in the channel to isStreaming=false. simulateAgentReply's token loop now re-reads the store each tick and bails out when the flag is flipped, so further tokens stop being appended (cooperative cancellation, no timer bookkeeping). 3. Placeholder copy is English and hardcoded: 'Message #channel' for channels, 'Message {name}' for DMs — drops the useT lookup that was routing through the zh locale. 4. Placeholder vertically centered by switching textarea padding from py-1.5 (12px) to py-2 (16px) so the 20px line-box fills the 36px collapsed height. Composer internal gap bumped from 0.5 (2px) to 2 (8px) so Attach and Send stop bumping shoulders. Made-with: Cursor --- .../src/components/chat/MessageInput.tsx | 98 +++++++++++++++---- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/apps/slark/src/renderer/src/components/chat/MessageInput.tsx b/apps/slark/src/renderer/src/components/chat/MessageInput.tsx index 95960a31..875f211b 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,52 @@ 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 ? ( + + ) : ( + + )} +
    From 280504126718cc58f650e916cf5e19b961cbe063 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 12:07:28 +0800 Subject: [PATCH 33/42] style(ui-web): monochrome reaction pills, drop brand-teal wash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'reacted' state was using brand-subtle + brand-primary text, which over-weighted emoji reactions in the feed — they read louder than the actual messages. Switching to surface-2 + text-primary keeps the reacted pill quiet while still clearly distinct from unreacted pills, and aligns the hover state with the reacted state so hovering foreshadows what clicking will do. Made-with: Cursor --- packages/ui-web/src/primitives/chat-message.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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} From 81ea45283ea7c3a1e794306996d4043800b4f2a2 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 13:53:12 +0800 Subject: [PATCH 34/42] feat(slark): standardise user presence (online/away/offline) + member panel polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `lib/user-presence.ts` centralises presence→dot-class / label mapping so every call site agrees on green = online, yellow = away, gray = offline. Replaces ad-hoc lookups across the app. - `globals.css`: add `--slark-color-nexu-away` (warm yellow) and lift `--slark-color-nexu-offline` to 0.65 lightness so the gray dot reads clearly on the sidebar background. - `AgentsSidebar`: drop the `useT` indirection and hardcode English copy (teammate list is a product surface, not content). Rename the section from "People" to "Members"; widen the spacing below the search input; add a bottom-right presence dot overlay on each avatar; hold the Owner badge in `text-brand-primary bg-brand-subtle` regardless of row-selection state; shade member email / agent description with `text-text-tertiary` so the name keeps the emphasis. - `UserDetail`: remove the in-corner avatar status dot and move the status affordance below the name (dot + textual label) so the profile read order is Name → Status → Email → Runtime. Adopt the canonical workspace content-panel wrapper (`max-w-[800px] mx-auto px-4 pt-2 pb-6 sm:px-6 sm:pb-8`) so this panel shares left/right margins with Settings and Agent Detail. Drop Chinese copy in favour of English. Made-with: Cursor --- apps/slark/src/renderer/src/app/globals.css | 9 +- .../src/components/agents/AgentsSidebar.tsx | 97 ++++++--- .../src/components/agents/UserDetail.tsx | 194 +++++++++--------- .../src/renderer/src/lib/user-presence.ts | 26 +++ 4 files changed, 201 insertions(+), 125 deletions(-) create mode 100644 apps/slark/src/renderer/src/lib/user-presence.ts diff --git a/apps/slark/src/renderer/src/app/globals.css b/apps/slark/src/renderer/src/app/globals.css index f5fb7422..c46c6c78 100644 --- a/apps/slark/src/renderer/src/app/globals.css +++ b/apps/slark/src/renderer/src/app/globals.css @@ -22,9 +22,15 @@ body, --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); @@ -53,6 +59,7 @@ body, --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/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/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"; +} From f9a160ae684e7d8ff84d6c03403df3f64b5ed026 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 13:53:20 +0800 Subject: [PATCH 35/42] refactor(slark): drop redundant section headers on workspace shell - `SettingsView` > Workspace tab: remove the card-level "Workspace details" title + description. The surrounding `PageHeader` already reads "Workspace settings", so repeating a second heading in the very next card just pushed the actual inputs down with no added information. - `Sidebar`: suppress the generic uppercase section label on the Runtimes route. `RuntimesSidebar` owns its own header row ("Runtimes" + "N/M online" on a single line, indented to match the content) and rendering both produced two differently-indented headers stacked on top of each other. Made-with: Cursor --- .../src/renderer/src/components/layout/Sidebar.tsx | 6 +++++- .../renderer/src/components/settings/SettingsView.tsx | 11 +++++------ 2 files changed, 10 insertions(+), 7 deletions(-) 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/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. */} From 872621c0b2d5e26aec409728f31eae9b51d514ba Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 13:53:39 +0800 Subject: [PATCH 36/42] =?UTF-8?q?feat(slark):=20runtimes=20panel=20?= =?UTF-8?q?=E2=80=94=20brand=20logos,=20copyable=20commands,=20English=20c?= =?UTF-8?q?opy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimesSidebar: - Custom header with "Runtimes" + "N/M online" on a single line, left-aligned to content (px-3). - Add button swapped from filled black to outline (size="xs" variant="outline") — it was sitting at the same visual weight as primary page CTAs. - Installed / detected list rows render `RuntimeLogo` instead of generic Lucide glyphs, so Claude Code / Codex / Gemini / Pi show their actual brand marks. - "Detected on this device" always renders a single `RefreshCw` icon button on the right, so refreshing no longer swaps text ↔ button and causes horizontal layout shift. - Hardcoded English copy (drop `useT`). RuntimesView: - Hardcoded English copy (drop `useT`, `TranslationKey`, `statusLabelKeys` in favour of a simple inline map) — status pill reads Online / Offline / Error. - Runtime logo now sits inside the canonical logo tile — fixed-size `rounded-xl/2xl` surface chip with `border-border-subtle` and `bg-surface-1`, matching the `provider-settings` pattern. No more blue / teal logo backgrounds. - Optical-size helper (`logoOpticalScale` + `getLogoSize`) bumps the glyphs for Codex / Gemini CLI / Pi that ship with intrinsic padding, so every brand mark reads at the same perceived weight. - Install-guide cards refactored so the entire card navigates to docs (absolute-positioned `` overlay) while the install command line is independently click-to-copy — new `InstallCommand` component with Copy / Check feedback. Jump icon switched to `ArrowUpRight` and aligned with the first line of the name. - Every `hover:bg-accent` / `bg-accent` in this view replaced with neutral `hover:bg-surface-2` or surface-2 + ring — `--color-accent` is near-black in light mode and turning it on as a hover state made the whole row look like the active/primary affordance. Made-with: Cursor --- .../components/runtimes/RuntimesSidebar.tsx | 89 +++--- .../src/components/runtimes/RuntimesView.tsx | 270 +++++++++++------- 2 files changed, 220 insertions(+), 139 deletions(-) diff --git a/apps/slark/src/renderer/src/components/runtimes/RuntimesSidebar.tsx b/apps/slark/src/renderer/src/components/runtimes/RuntimesSidebar.tsx index d1b6ea5e..1d71ab9a 100644 --- a/apps/slark/src/renderer/src/components/runtimes/RuntimesSidebar.tsx +++ b/apps/slark/src/renderer/src/components/runtimes/RuntimesSidebar.tsx @@ -1,8 +1,7 @@ -import { Button, cn } from "@nexu-design/ui-web"; -import { Box, Code, Cpu, MousePointer, RefreshCw, Sparkles, Terminal } from "lucide-react"; +import { Button, RuntimeLogo, cn } from "@nexu-design/ui-web"; +import { RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useT } from "@/i18n"; import { mockUsers } from "@/mock/data"; import { useRuntimesStore } from "@/stores/runtimes"; import { useWorkspaceStore } from "@/stores/workspace"; @@ -41,19 +40,9 @@ const MOCK_DETECTION_POOL: DetectedRuntime[] = [ { type: "codex", name: "Codex", desc: "OpenAI", version: "0.3.1", path: "/usr/local/bin/codex" }, ]; -const typeIcons: Record = { - "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 ` - - {channel.type === "channel" && ( + /* + * Phase 1 chat header is a single flat bar — no Messages / Files / Artifacts + * tabs, no right-side topic detail panel. Both features live on + * `feature/chat-tabs-and-topic-panel` and will return in a later release. + * + * Header contract still in effect: + * - Neutral hover fill on the title + members chip (`hover:bg-surface-2`), + * never `bg-accent` (near-black) which would flood the row with brand + * color on mouseover. + * - Channels surface a members chip (Users icon + count) inline right + * after the title; clicking opens the add-members dialog. Descriptions + * are deliberately not rendered in the header — if a channel needs + * description context, surface it in the body, not the chrome. + */ + return ( +
    + - )} - - ); - 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." - /> -
    -
    + {channel.type === "channel" && ( + + )} + - -
    - } - title="Artifacts" - description="Agent artifacts will appear here as your team runs workflows." - /> -
    -
    - - ) : ( - <> - - {headerRow} - - - - - )} + + {channel.type === "channel" && ( void; onExpand?: (block: ContentBlock) => void; - onTopicOpen?: (block: Extract) => void; } function formatFileSize(bytes: number): string { @@ -672,7 +667,6 @@ export function ContentBlockRenderer({ block, onApprovalAction, onExpand, - onTopicOpen, }: ContentBlockRendererProps): React.ReactElement { const handleExpand = (): void => onExpand?.(block); @@ -702,8 +696,6 @@ export function ContentBlockRenderer({ case "agent-run": return ; case "topic": - return ( - onTopicOpen(block) : undefined} /> - ); + return ; } } diff --git a/apps/slark/src/renderer/src/components/chat/MessageList.tsx b/apps/slark/src/renderer/src/components/chat/MessageList.tsx index 174ed523..d1cb1789 100644 --- a/apps/slark/src/renderer/src/components/chat/MessageList.tsx +++ b/apps/slark/src/renderer/src/components/chat/MessageList.tsx @@ -115,12 +115,6 @@ 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[] = []; @@ -272,11 +266,7 @@ function getQuickPrompts(name: string, templateId: string | null): string[] { } } -export function MessageList({ - channelId, - channel, - onTopicOpen, -}: MessageListProps): React.ReactElement { +export function MessageList({ channelId, channel }: 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; @@ -400,7 +390,6 @@ export function MessageList({ 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 deleted file mode 100644 index 8cf7f8fa..00000000 --- a/apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx +++ /dev/null @@ -1,389 +0,0 @@ -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/types/index.ts b/apps/slark/src/renderer/src/types/index.ts index a9dd8450..20d84e10 100644 --- a/apps/slark/src/renderer/src/types/index.ts +++ b/apps/slark/src/renderer/src/types/index.ts @@ -161,12 +161,13 @@ export type ContentBlock = 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. + * Conversation under this topic — the reply thread that will be + * surfaced in the right-side topic detail panel (deferred to a later + * release on `feature/chat-tabs-and-topic-panel`). Kept on the type + * today so mock data stays valid and the feature branch merges + * cleanly. 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[]; }; From d55e07842ec5d97b3a573581558414093acb509d Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Tue, 21 Apr 2026 14:06:46 +0800 Subject: [PATCH 42/42] feat(slark): channel header tabs + topic detail side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reintroduces the two chat-product features that were removed from the phase-1 PR #34 in 80ffbf4 so they can ship on their own review cycle. Channel header tabs (Messages / Files / Artifacts) - `ChatView` wraps the channel layout in `` with three surfaces: Messages (the live feed + composer), Files (EmptyState placeholder for now), Artifacts (EmptyState placeholder, reserved for future agent-output browsing). Tab labels are hardcoded English per the "tabs are orientation, not user content" convention that also governs the sidebar section headers. - Header chrome is a single unified border-b block — title row and tab row share one surface, no divider between them, so it reads as one piece of chrome instead of two stacked bars. - DMs intentionally keep the flat single-pane layout; tabs only render for `channel`-type channels. Topic → right-side detail panel (Thread / Files / Members / Pinned) - Clicking a topic card opens a persistent right-side panel instead of doing nothing. Primary tab is Thread (reply conversation with inline images + link previews); Files / Members / Pinned are secondary. - Layout is *push*, not overlay: the Messages tab's flex row animates a right column from width 0 → 380px over 200ms (ease-standard). No backdrop / scrim — the message list reflows. Closing keeps `activeTopic` until the width transition ends so the inner content doesn't flash empty mid-animation. - State lives in `ChatView` and resets on channel switch. The click path threads a dedicated `onTopicOpen` callback through `MessageList` → `ContentBlockRenderer` → `TopicBlock`; it is kept intentionally separate from `onExpand` (which drives the fullscreen modal overlay for code / diff / image) because topics are tracked threads, not transient previews. - `TopicDetailPanel` composes existing ui-web primitives (`DetailPanel`, `Tabs`, `EmptyState`) + reuses the `TopicThreadMessage` mock shape already on `types/index.ts`. No other changes vs the PR #34 tip: this branch is exactly `fix/slark-nav-active-brand-wash` HEAD + this one commit, so once PR #34 merges, the diff of this PR against `main` collapses to these two features alone. Made-with: Cursor --- .../renderer/src/components/chat/ChatView.tsx | 235 ++++++++--- .../src/components/chat/ContentBlocks.tsx | 22 +- .../src/components/chat/MessageList.tsx | 13 +- .../src/components/chat/TopicDetailPanel.tsx | 389 ++++++++++++++++++ apps/slark/src/renderer/src/types/index.ts | 13 +- 5 files changed, 611 insertions(+), 61 deletions(-) create mode 100644 apps/slark/src/renderer/src/components/chat/TopicDetailPanel.tsx diff --git a/apps/slark/src/renderer/src/components/chat/ChatView.tsx b/apps/slark/src/renderer/src/components/chat/ChatView.tsx index 80671006..142ec051 100644 --- a/apps/slark/src/renderer/src/components/chat/ChatView.tsx +++ b/apps/slark/src/renderer/src/components/chat/ChatView.tsx @@ -1,16 +1,23 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; -import { AtSign, Bot, Globe, MessageSquare, Users } 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(); @@ -21,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; @@ -111,56 +160,150 @@ export function ChatView(): React.ReactElement { channel.type === "dm" ? channel.members.find((m) => m.id !== "u-1") : undefined; const otherResolved = otherMember ? resolveRef(otherMember) : undefined; - /* - * Phase 1 chat header is a single flat bar — no Messages / Files / Artifacts - * tabs, no right-side topic detail panel. Both features live on - * `feature/chat-tabs-and-topic-panel` and will return in a later release. - * - * Header contract still in effect: - * - Neutral hover fill on the title + members chip (`hover:bg-surface-2`), - * never `bg-accent` (near-black) which would flood the row with brand - * color on mouseover. - * - Channels surface a members chip (Users icon + count) inline right - * after the title; clicking opens the add-members dialog. Descriptions - * are deliberately not rendered in the header — if a channel needs - * description context, surface it in the body, not the chrome. - */ - 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.type === "channel" && ( - - )} - + 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" && ( void; onExpand?: (block: ContentBlock) => void; + onTopicOpen?: (block: Extract) => void; } function formatFileSize(bytes: number): string { @@ -667,6 +672,7 @@ export function ContentBlockRenderer({ block, onApprovalAction, onExpand, + onTopicOpen, }: ContentBlockRendererProps): React.ReactElement { const handleExpand = (): void => onExpand?.(block); @@ -696,6 +702,8 @@ export function ContentBlockRenderer({ case "agent-run": return ; case "topic": - return ; + return ( + onTopicOpen(block) : undefined} /> + ); } } diff --git a/apps/slark/src/renderer/src/components/chat/MessageList.tsx b/apps/slark/src/renderer/src/components/chat/MessageList.tsx index d1cb1789..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; @@ -390,6 +400,7 @@ 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/types/index.ts b/apps/slark/src/renderer/src/types/index.ts index 20d84e10..a9dd8450 100644 --- a/apps/slark/src/renderer/src/types/index.ts +++ b/apps/slark/src/renderer/src/types/index.ts @@ -161,13 +161,12 @@ export type ContentBlock = preview?: string; assignee?: { name: string; isAgent?: boolean; accent?: string }; /** - * Conversation under this topic — the reply thread that will be - * surfaced in the right-side topic detail panel (deferred to a later - * release on `feature/chat-tabs-and-topic-panel`). Kept on the type - * today so mock data stays valid and the feature branch merges - * cleanly. 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. + * 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[]; };