Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3154,6 +3160,7 @@ pub async fn workspace_list(state: State<'_, AppState>) -> Result<Vec<WorkspaceL
next_run_in_seconds,
unread: locator.last_run_completed_at > 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(),
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions src-tauri/src/config/workspace_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub default_agent_id: String,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/workspace_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 37 additions & 1 deletion src/components/Fleet/WorkspaceRail.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -408,7 +443,8 @@
gap: 2px;
}

.row:hover .rowActions {
.row:hover .rowActions,
.row:focus-within .rowActions {
display: inline-flex;
}

Expand Down
182 changes: 182 additions & 0 deletions src/components/Fleet/WorkspaceRail.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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<React.ComponentProps<typeof WorkspaceRail>> = {},
) =>
render(
<WorkspaceRail
workspaces={workspaces}
selectedId={null}
attentionCounts={{}}
activeRuns={{}}
collapsed={false}
onToggleCollapsed={noop}
onSelect={noop}
onCreate={noop}
onRunNow={noop}
onTogglePause={noop}
onToggleStar={noop}
onSettings={noop}
onFork={noop}
onDelete={noop}
runNowBusyId={null}
forkBusyId={null}
pauseBusyId={null}
schedulerPaused={false}
schedulerPauseBusy={false}
onToggleSchedulerPaused={noop}
{...overrides}
/>,
);

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();
});
});
Loading
Loading