From 8d64eb64462013c0b20365e7066f0c58e29fb9e4 Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:29:33 +0200 Subject: [PATCH] feat(rail): labeled sections + starred workspaces in the workspace rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail previously merged three invisible sort keys (attention, then scheduled, then recency) into one flat list — scheduled workspaces always floated to the top with nothing explaining why, and long lists were hard to scan. Redesign it around explicit, labeled sections (Claude-style): 1. Needs attention — pending approvals, failed or blocked tasks 2. Starred — user-pinned workspaces (new) 3. Recent — everything else, most-recently-updated first Scheduled workspaces no longer jump the queue: their cadence renders as a per-row label (an attribute, not a position). Headers only appear when grouping is in effect; a bare recency list stays headerless, and the collapsed rail separates sections with thin dividers instead. Starring: hover star toggle + overflow-menu item per row, persisted as WorkspaceConfig.starred_at (timestamp, 0 = unstarred) via the new workspace_set_starred command. Starring deliberately does not bump updated_at, so it never reorders the recency sort. Forks start unstarred. The flag is mirrored onto WorkspaceLocator so workspace_list derives it without re-reading configs. --- src-tauri/src/commands/workspace.rs | 26 + src-tauri/src/config/workspace_config.rs | 27 + src-tauri/src/lib.rs | 1 + src-tauri/src/workspace_index.rs | 4 + src/components/Fleet/WorkspaceRail.module.css | 38 +- src/components/Fleet/WorkspaceRail.test.tsx | 182 ++++++ src/components/Fleet/WorkspaceRail.tsx | 542 ++++++++++-------- src/generated/bindings.ts | 7 +- src/layouts/FleetLayout.tsx | 25 + src/workspace/client.ts | 12 + 10 files changed, 637 insertions(+), 227 deletions(-) create mode 100644 src/components/Fleet/WorkspaceRail.test.tsx diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index aecd4ce3..0fcbdc8b 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -2883,6 +2883,9 @@ pub struct WorkspaceListEntry { /// the rail renders an "unread" dot until `workspace_mark_opened` /// clears it. pub unread: bool, + /// User starred this workspace — the rail pins it in the "Starred" + /// section. Derived from `WorkspaceConfig::starred_at > 0`. + pub starred: bool, pub created_at: i64, pub updated_at: i64, } @@ -3034,6 +3037,9 @@ pub async fn workspace_fork( // unread/seen state. last_run_completed_at: 0, last_opened_at: 0, + // A star marks the SOURCE workspace as important; the fork starts + // life unstarred like any other new workspace. + starred_at: 0, default_agent_id, schedule, agents, @@ -3154,6 +3160,7 @@ pub async fn workspace_list(state: State<'_, AppState>) -> Result 0 && locator.last_run_completed_at > locator.last_opened_at, + starred: locator.starred_at > 0, created_at: load_workspace_config_for_id(state.inner(), &locator.id) .map(|(_, config)| config.created_at) .unwrap_or_default(), @@ -3509,6 +3516,25 @@ pub async fn workspace_mark_opened( Ok(()) } +/// Star or unstar a workspace (the rail pins starred workspaces in a +/// dedicated section). Persists a timestamp (0 = unstarred). Deliberately +/// does NOT bump `updated_at`, so starring never reorders the +/// recency-sorted workspace list. +#[tauri::command] +pub async fn workspace_set_starred( + workspace_id: String, + starred: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspace_id = resolve_workspace_id(state.inner(), Some(workspace_id))?; + update_workspace_config_for_id(state.inner(), &workspace_id, |config| { + config.starred_at = if starred { now_millis() } else { 0 }; + Ok(()) + })?; + + Ok(()) +} + async fn workspace_task_attention_summary( pool: &DbPool, _workspace_id: &str, diff --git a/src-tauri/src/config/workspace_config.rs b/src-tauri/src/config/workspace_config.rs index 0ef62cbf..679ac28b 100644 --- a/src-tauri/src/config/workspace_config.rs +++ b/src-tauri/src/config/workspace_config.rs @@ -145,6 +145,11 @@ pub struct WorkspaceConfig { /// would reorder the rail's recency sort just by looking at a workspace. #[serde(default)] pub last_opened_at: i64, + /// Unix ms when the user starred this workspace in the rail, or 0 when + /// not starred. Stored as a timestamp (not a bool) so the UI can sort + /// recently-starred entries first if it ever wants to; `> 0` = starred. + #[serde(default)] + pub starred_at: i64, #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_provider_connection_id: Option, pub default_agent_id: String, @@ -214,6 +219,7 @@ impl WorkspaceConfig { updated_at: now, last_run_completed_at: 0, last_opened_at: 0, + starred_at: 0, preferred_provider_connection_id: None, default_agent_id: manager_id.clone(), schedule: WorkspaceSchedule::default(), @@ -752,4 +758,25 @@ mod attach_provider_tests { assert_eq!(on_disk.schedule.next_run_at_unix_ms, Some(999)); assert_eq!(on_disk.last_opened_at, 555); } + + /// `starredAt` must default to 0 (unstarred) for configs written before + /// the field existed, and survive an `update` roundtrip once set. + #[test] + fn starred_at_defaults_to_zero_and_roundtrips() { + // Legacy config JSON without the field deserializes as unstarred. + let mut legacy = serde_json::to_value(workspace()).unwrap(); + legacy.as_object_mut().unwrap().remove("starredAt"); + let parsed: WorkspaceConfig = serde_json::from_value(legacy).unwrap(); + assert_eq!(parsed.starred_at, 0); + + // Set-then-load roundtrip through the atomic updater. + let tmp = tempfile::tempdir().unwrap(); + save(tmp.path(), &workspace()).unwrap(); + update(tmp.path(), |config| { + config.starred_at = 1234; + Ok(()) + }) + .unwrap(); + assert_eq!(load(tmp.path()).unwrap().starred_at, 1234); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce800589..26c9953a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -419,6 +419,7 @@ pub fn run() { commands::workspace::workspace_delete, commands::workspace::workspace_set_title, commands::workspace::workspace_mark_opened, + commands::workspace::workspace_set_starred, commands::workspace_agents::workspace_get_agent, commands::workspace_agents::workspace_create_agent, commands::workspace_agents::workspace_update_agent, diff --git a/src-tauri/src/workspace_index.rs b/src-tauri/src/workspace_index.rs index 0870f762..e55709e0 100644 --- a/src-tauri/src/workspace_index.rs +++ b/src-tauri/src/workspace_index.rs @@ -19,6 +19,9 @@ pub struct WorkspaceLocator { /// `insert_config`. pub last_run_completed_at: i64, pub last_opened_at: i64, + /// Mirrors `WorkspaceConfig::starred_at` (> 0 = starred) so the rail's + /// "Starred" section can be derived without re-reading configs. + pub starred_at: i64, pub default_agent_id: String, pub schedule_enabled: bool, pub schedule_paused: bool, @@ -137,6 +140,7 @@ impl WorkspaceIndex { updated_at: config.updated_at, last_run_completed_at: config.last_run_completed_at, last_opened_at: config.last_opened_at, + starred_at: config.starred_at, default_agent_id: config.default_agent_id.clone(), schedule_enabled: config.schedule.enabled, schedule_paused: config.schedule.paused, diff --git a/src/components/Fleet/WorkspaceRail.module.css b/src/components/Fleet/WorkspaceRail.module.css index f106a1dc..c98dfe44 100644 --- a/src/components/Fleet/WorkspaceRail.module.css +++ b/src/components/Fleet/WorkspaceRail.module.css @@ -229,6 +229,41 @@ color: var(--color-text-primary); } +/* Labeled group headings (Needs attention / Starred / Recent). Only + rendered when grouping is in effect — a bare recency list stays + headerless. */ +.sectionHeader { + display: flex; + align-items: center; + gap: 5px; + padding: 8px 9px 3px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-tertiary); + user-select: none; +} + +.sectionHeaderIcon { + display: inline-flex; + align-items: center; +} + +/* Collapsed rail has no room for headers — a thin divider separates the + sections instead. */ +.sectionDivider { + flex-shrink: 0; + height: 1px; + margin: 4px 6px; + background: var(--color-border, var(--color-overlay-08)); +} + +/* Filled/star state of the per-row star toggle. */ +.starButtonActive { + color: var(--color-warning-dark, #b8860b); +} + .railList { flex: 1; overflow-y: auto; @@ -408,7 +443,8 @@ gap: 2px; } -.row:hover .rowActions { +.row:hover .rowActions, +.row:focus-within .rowActions { display: inline-flex; } diff --git a/src/components/Fleet/WorkspaceRail.test.tsx b/src/components/Fleet/WorkspaceRail.test.tsx new file mode 100644 index 00000000..7bcf3268 --- /dev/null +++ b/src/components/Fleet/WorkspaceRail.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import WorkspaceRail from './WorkspaceRail'; +import type { WorkspaceListEntry } from '../../generated/bindings'; + +// Typed against the generated bindings so a backend field rename fails this +// test at compile time instead of silently making the mock stale. +const entry = ( + id: string, + title: string, + overrides: Partial = {}, +): WorkspaceListEntry => ({ + id, + kind: 'general', + title, + agentId: null, + enabled: true, + messageCount: 0n, + assignedAgentCount: 1, + defaultManagerName: null, + runningTaskCount: 0n, + blockedTaskCount: 0n, + failedTaskCount: 0n, + attentionTaskCount: 0n, + latestAttentionTaskId: null, + latestAttentionTaskTitle: null, + latestAttentionTaskStatus: null, + latestAttentionTaskSummary: null, + latestAttentionTaskUpdatedAt: null, + scheduleEnabled: false, + schedulePaused: false, + scheduleKind: null, + nextRunInSeconds: null, + unread: false, + starred: false, + createdAt: 1n, + updatedAt: 1n, + ...overrides, +}); + +const noop = () => {}; + +const renderRail = ( + workspaces: WorkspaceListEntry[], + overrides: Partial> = {}, +) => + render( + , + ); + +describe('WorkspaceRail sections', () => { + it('renders a plain headerless list when nothing is starred or in attention', () => { + renderRail([ + entry('a', 'Alpha', { updatedAt: 3n }), + entry('b', 'Beta', { updatedAt: 2n, scheduleEnabled: true }), + entry('c', 'Gamma', { updatedAt: 1n }), + ]); + expect(screen.queryByText('Recent')).toBeNull(); + expect(screen.queryByText('Starred')).toBeNull(); + expect(screen.queryByText('Needs attention')).toBeNull(); + // Scheduled workspaces no longer jump the queue: pure recency order. + const titles = screen + .getAllByText(/Alpha|Beta|Gamma/) + .map((el) => el.textContent); + expect(titles).toEqual(['Alpha', 'Beta', 'Gamma']); + }); + + it('groups starred workspaces under a labeled Starred section above Recent', () => { + renderRail([ + entry('a', 'Alpha', { updatedAt: 3n }), + entry('b', 'Beta', { updatedAt: 2n, starred: true }), + entry('c', 'Gamma', { updatedAt: 1n }), + ]); + expect(screen.getByText('Starred')).toBeTruthy(); + expect(screen.getByText('Recent')).toBeTruthy(); + const titles = screen + .getAllByText(/Alpha|Beta|Gamma/) + .map((el) => el.textContent); + // Starred (Beta) first, then Recent in recency order (Alpha, Gamma). + expect(titles).toEqual(['Beta', 'Alpha', 'Gamma']); + }); + + it('pins attention workspaces in a labeled section that outranks Starred', () => { + renderRail( + [ + entry('a', 'Alpha', { updatedAt: 3n, starred: true }), + entry('b', 'Beta', { updatedAt: 2n }), + entry('c', 'Gamma', { updatedAt: 1n, failedTaskCount: 1n, starred: true }), + ], + { attentionCounts: { b: 2 } }, + ); + expect(screen.getByText('Needs attention')).toBeTruthy(); + const titles = screen + .getAllByText(/Alpha|Beta|Gamma/) + .map((el) => el.textContent); + // Attention: Beta (pending approvals) then Gamma (failed task), by + // recency. Starred Alpha follows. Gamma sits under attention even + // though starred — attention outranks the star. + expect(titles).toEqual(['Beta', 'Gamma', 'Alpha']); + expect(screen.queryByText('Recent')).toBeNull(); + }); + + it('fires onToggleStar from the hover star button with the current state', async () => { + const onToggleStar = vi.fn(); + renderRail( + [entry('a', 'Alpha'), entry('b', 'Beta', { starred: true })], + { onToggleStar }, + ); + await userEvent.click(screen.getByRole('button', { name: 'Star workspace' })); + expect(onToggleStar).toHaveBeenCalledWith('a', false); + await userEvent.click(screen.getByRole('button', { name: 'Unstar workspace' })); + expect(onToggleStar).toHaveBeenCalledWith('b', true); + }); + + it('offers star/unstar in the per-row overflow menu', async () => { + const onToggleStar = vi.fn(); + renderRail([entry('a', 'Alpha')], { onToggleStar }); + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + const menu = screen.getByRole('menu'); + await userEvent.click(within(menu).getByRole('menuitem', { name: 'Star workspace' })); + expect(onToggleStar).toHaveBeenCalledWith('a', false); + }); + + it('filter searches across sections and hides emptied ones', async () => { + renderRail([ + entry('a', 'Alpha', { starred: true }), + entry('b', 'Beta'), + ]); + await userEvent.type( + screen.getByRole('textbox', { name: 'Filter workspaces by name' }), + 'bet', + ); + expect(screen.queryByText('Starred')).toBeNull(); + expect(screen.getByText('Beta')).toBeTruthy(); + expect(screen.queryByText('Alpha')).toBeNull(); + }); + + it('keeps the Starred header when every workspace is starred', () => { + renderRail([ + entry('a', 'Alpha', { starred: true }), + entry('b', 'Beta', { starred: true }), + ]); + // A lone non-Recent section still labels itself — otherwise the list + // would silently look like a plain recency list while being pinned. + expect(screen.getByText('Starred')).toBeTruthy(); + expect(screen.queryByText('Recent')).toBeNull(); + }); + + it('collapsed rail shows no headers', () => { + renderRail( + [entry('a', 'Alpha', { starred: true }), entry('b', 'Beta')], + { collapsed: true }, + ); + expect(screen.queryByText('Starred')).toBeNull(); + expect(screen.queryByText('Recent')).toBeNull(); + }); +}); diff --git a/src/components/Fleet/WorkspaceRail.tsx b/src/components/Fleet/WorkspaceRail.tsx index bc825031..2dcc241e 100644 --- a/src/components/Fleet/WorkspaceRail.tsx +++ b/src/components/Fleet/WorkspaceRail.tsx @@ -21,6 +21,7 @@ interface WorkspaceRailProps { onCreate: () => void; onRunNow: (id: string) => void; onTogglePause: (id: string, currentlyPaused: boolean) => void; + onToggleStar: (id: string, currentlyStarred: boolean) => void; onSettings: (id: string) => void; onFork: (id: string) => void; onDelete: (id: string, title: string) => void; @@ -52,6 +53,12 @@ const hasAttention = ( num(ws.failedTaskCount) > 0 || num(ws.blockedTaskCount) > 0; +type RailSection = { + key: 'attention' | 'starred' | 'recent'; + label: string; + items: WorkspaceListEntry[]; +}; + const RunIcon = () => ( ); +const StarIcon = ({ filled }: { filled: boolean }) => ( + +); + /** * Persistent left navigator for the unified Fleet/Workspace view. Lists - * every workspace; clicking a row selects it (the host navigates to - * `/workspace/:id`). Attention workspaces (pending approval, failed or - * blocked tasks) are pinned to the top with a badge so cross-workspace - * issues surface without a separate panel. + * every workspace grouped into explicit, labeled sections (Claude-style) + * so ordering is never a mystery: + * + * 1. "Needs attention" — pending approvals, failed or blocked tasks. + * 2. "Starred" — user-pinned workspaces (star via hover icon or ⋯ menu). + * 3. "Recent" — everything else, most-recently-updated first. + * + * Within each section rows keep the recency order. Scheduled workspaces + * are NOT sorted separately — their cadence renders as a per-row label, + * an attribute rather than a position. Section headers only appear when + * grouping is actually in effect (a bare recency list stays headerless). * * Collapsible: the collapsed state shows just a status dot + initial, - * with the full title on hover (title attr). The host owns the collapsed - * flag (persisted to localStorage) and all data/actions; this component - * is presentational plus a small amount of per-row menu state. + * with the full title on hover (title attr) and thin dividers between + * sections. The host owns the collapsed flag (persisted to localStorage) + * and all data/actions; this component is presentational plus a small + * amount of per-row menu state. */ const WorkspaceRail = ({ workspaces, @@ -91,6 +122,7 @@ const WorkspaceRail = ({ onToggleSchedulerPaused, onRunNow, onTogglePause, + onToggleStar, onSettings, onFork, onDelete, @@ -103,19 +135,10 @@ const WorkspaceRail = ({ const [query, setQuery] = useState(''); const [dropTargetId, setDropTargetId] = useState(null); - // Sort: attention first, then scheduled, then most-recently-updated. + // Pure recency sort — grouping happens per-section below. const sorted = useMemo( - () => - [...workspaces].sort((a, b) => { - const aAtt = hasAttention(a, attentionCounts); - const bAtt = hasAttention(b, attentionCounts); - if (aAtt !== bAtt) return aAtt ? -1 : 1; - const aSched = !!a.scheduleEnabled; - const bSched = !!b.scheduleEnabled; - if (aSched !== bSched) return aSched ? -1 : 1; - return num(b.updatedAt) - num(a.updatedAt); - }), - [workspaces, attentionCounts], + () => [...workspaces].sort((a, b) => num(b.updatedAt) - num(a.updatedAt)), + [workspaces], ); // Name filter. Applied only when expanded — collapsed has no input, so @@ -126,6 +149,264 @@ const WorkspaceRail = ({ return sorted.filter((ws) => (ws.title || '').toLowerCase().includes(q)); }, [sorted, query, collapsed]); + // Partition into labeled sections. Attention outranks starred: a starred + // workspace that needs input surfaces under "Needs attention" (the star + // state stays visible via the hover toggle / menu). Empty sections are + // dropped entirely. + const sections = useMemo(() => { + const attention: WorkspaceListEntry[] = []; + const starred: WorkspaceListEntry[] = []; + const recent: WorkspaceListEntry[] = []; + for (const ws of visible) { + if (hasAttention(ws, attentionCounts)) attention.push(ws); + else if (ws.starred) starred.push(ws); + else recent.push(ws); + } + return [ + { key: 'attention' as const, label: 'Needs attention', items: attention }, + { key: 'starred' as const, label: 'Starred', items: starred }, + { key: 'recent' as const, label: 'Recent', items: recent }, + ].filter((section) => section.items.length > 0); + }, [visible, attentionCounts]); + + // A lone "Recent" section is just a plain list — no header noise. + const showHeaders = + sections.length > 1 || (sections.length === 1 && sections[0]?.key !== 'recent'); + + const renderRow = (ws: WorkspaceListEntry) => { + const processing = isProcessing(ws, activeRuns); + const pending = attentionCounts[ws.id] || 0; + const status = deriveCardStatus(ws, processing, pending > 0); + const isSelected = ws.id === selectedId; + const scheduleLabel = formatScheduleLabel(ws.scheduleKind); + const isPaused = !!ws.schedulePaused; + const isStarred = !!ws.starred; + const attentionCount = pending + num(ws.failedTaskCount) + num(ws.blockedTaskCount); + // A run completed since the user last opened this workspace. + // Suppressed while selected — the open page is marking it seen. + const isUnread = !!ws.unread && !isSelected; + const initial = (ws.title || '?').trim().charAt(0).toUpperCase() || '?'; + const rowClasses = [styles.row, isSelected ? styles.rowSelected : ''].join(' '); + + return ( +
onSelect(ws.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') onSelect(ws.id); + }} + onDragOver={(e) => { + if (!onArtifactDrop) return; + if (!e.dataTransfer.types.includes('application/x-clai-artifact')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + if (dropTargetId !== ws.id) setDropTargetId(ws.id); + }} + onDragLeave={() => { + setDropTargetId((prev) => (prev === ws.id ? null : prev)); + }} + onDrop={(e) => { + if (!onArtifactDrop) return; + const raw = e.dataTransfer.getData('application/x-clai-artifact'); + setDropTargetId(null); + if (!raw) return; + e.preventDefault(); + try { + const drag = JSON.parse(raw); + if ( + drag && + typeof drag.path === 'string' && + typeof drag.workspaceId === 'string' && + typeof drag.kind === 'string' && + typeof drag.name === 'string' + ) { + onArtifactDrop(ws.id, drag); + } + } catch { + // Ignore malformed / foreign drops. + } + }} + title={collapsed ? ws.title : undefined} + > +
+ + )} + + )} + + ); + }; + return (