mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[73275ff0] Panel consistency & UX wave: forms audit, command palette, kanban merge, responsiveness (#694)
* [170c9578] Frontend: Panel consistency & UX wave (forms audit, command palette, kanban merge, responsiveness) (#688) * [f1957610] Stream1-A: Project form sync (#667) * [f1957610] feat(panel): expose codegen_command in create-project dialog Add the Codegen Command input to create-project-dialog.tsx, mirroring the field already present in edit-project-dialog.tsx. All other fields named in this task (git_provider, github_installation_id, environments, protected_branches, video_engine_enabled, monthly_budget_usd with gt=0 client validation, sandbox_extensions) were already implemented on this branch's base by prior work, and the ProjectCreate/ProjectUpdate types in types/index.ts already match the backend ProjectCreateRequest/ProjectUpdateRequest schemas exactly -- no further changes were needed there. * [f1957610] docs(forms): add project-fields-audit reference for future field consistency Create a living audit of which project configuration fields are exposed in the create vs. edit dialogs, mapping to the backend ProjectCreateRequest/ProjectUpdateRequest schemas. This serves as a future reference to prevent field-sync gaps and documents the intentional asymmetry (create focuses on git setup, edit adds autonomy/maintenance toggles). Includes a checklist for adding new project fields in the future. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [515697f4] feat(panel): settings save feedback + forms-audit.md living reference (#669) Add per-toggle confirmation toasts to the four Settings-page prefs (notifications, sound, auto refresh, refresh interval) so an immediate write is never indistinguishable from a silent failure. These prefs stay on the already-shipped client-persisted useUIStore pattern (CHANGELOG.md "Settings preferences persist as real client prefs instead of 422-ing as theater") rather than settingsApi, since the backend _VALIDATORS allowlist deliberately excludes them and the parent task scoped this stream as needing no backend schema changes. Check in docs/forms-audit.md: a living form x field x verdict table covering Stream1-A (project dialogs), Stream1-B (task dialogs), and this settings work, with a header note that future backend schema changes require a row update. Fixes the project-slug help text (now correctly says letters/numbers/hyphens, not just hyphens). Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> * [80a215a4] Stream2-A: Command palette component (#670) * [80a215a4] feat(panel): Cmd+K command palette component Radix Dialog + combobox pattern searching tasks/agents/projects/pages, localStorage recents under roboco-cmd-recents, keyboard nav (arrows/ Enter/Escape), mounted globally in the dashboard layout. * [80a215a4] fix(panel): restore fields dropped from ui-store.ts by prior merge Stream1-C's merge stripped notificationsEnabled, soundEnabled, autoRefresh, refreshIntervalSeconds, a2aContextOpen, quickActionIds, productsView, and projectsView from the shared UI store, breaking typecheck for settings/quick-actions/products/projects/a2a/notification consumers repo-wide. Restored per already-committed tests + consumers. * [80a215a4] docs(panel): add command palette reference guide Documents the global Cmd+K search feature: usage (keyboard shortcuts, search categories, recents), architecture (CommandPalette component, useCommandPalette hook, fuzzy-match and recents helpers), data flow, and verification against live API data. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [cdc371d1] Stream4-A: Responsiveness audit and fix — wide-content pages (#671) * [cdc371d1] fix(panel): bump Button sm size to 36px touch-target floor Button's size="sm" variant was h-8 (32px), used as literal row-action buttons on the overview page's CEO Approval/PR Review queues and other controls across settings/metrics/agents/a2a. Bump to h-9 (36px) to meet the touch-target floor everywhere at once, keeping the smaller horizontal padding/gap intact for visual density. * [cdc371d1] fix(panel): make AlertDialog scroll its body at short viewport heights AlertDialogContent lacked the max-h-[85vh]/overflow-y-auto that the sibling DialogContent already has, and AlertDialogFooter lacked DialogFooter's sticky bottom-0 pinning. A tall description at a short viewport height (mobile landscape) could clip the action buttons off screen with no way to reach them. Affects the settings page's GitHubAppCredentialsCard/FeatureFlagsCard confirm dialogs (and every other AlertDialog app-wide). Ports DialogContent's already-solved scroll pattern onto AlertDialogContent/Footer. * [cdc371d1] fix(panel): wrap Scorecards Members table in ResponsiveTable metrics/scorecards-tab.tsx's 9-column Members table was a bare <Table> with no mobile-card fallback, unlike its sibling tables in the same file (Rework, SpawnWaste) and sessions-table.tsx, which already use the established ResponsiveTable wrapper. Add a MemberCard component and wrap the table so it stacks as cards below md instead of forcing a cramped in-card horizontal scroll on a 375px viewport. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [b25fca69] Stream2-B: Header integration for command palette (#675) * [b25fca69] Wire header search into Stream2-A command palette: click trigger via useUIStore.setCommandPaletteOpen, remove disabled input and Coming Soon tooltip remnants * [b25fca69] Wire header search into Stream2-A command palette: click trigger via useUIStore.setCommandPaletteOpen, remove disabled input and Coming Soon tooltip remnants --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [e4ce5b9a] Stream3-A: Tasks page List|Kanban tabs + kanban embed (#674) * [e4ce5b9a] feat(tasks): add List|Kanban tabs to tasks page sharing URL filter state Add top-level List|Kanban Tabs above the tasks page filter bar. List tab renders the existing TaskFilters+TaskTable unchanged; Kanban tab embeds the existing DevKanban/QaKanban/PrReviewKanban/PmKanban views via nested sub-tabs (dev/qa/pr-review/pm), mirroring the standalone /kanban page's own tab styling (tooltip-wrapped triggers, pickTab helper). Both tabs read/write `tab`/`view` query params through the page's existing updateParams pattern, so all filters persist across tab switches. The four kanban view wrappers gain an optional controlled team/onTeamChange pair so the team filter is shared bidirectionally with the List tab's team filter, while staying backward compatible (uncontrolled, initialTeam-only) for the standalone /kanban route. KanbanBoard's dnd-kit drag-and-drop and mobile single-column navigation are untouched. * [e4ce5b9a] docs(tasks): add tasks-page-tabs.md documenting List|Kanban tab structure and shared filter state --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [18c52802] feat(panel): redirect /kanban to Tasks kanban tab, remove sidebar entry, swap bottom tab bar to Agents (#679) Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * Stream4-B: Responsiveness fixes — remaining dashboard pages (#676) * [fee25542] fix(a11y): bump sub-36px icon-sm touch targets to 36px on remaining pages Both kb-search-bar.tsx (Knowledge Base search clear button) and self-hosted-section.tsx (Settings token show/hide button) used Button size="icon-sm" (32px) for an absolutely-positioned input adornment, below the 36px minimum touch-target size. Bumped both to size="icon" (36px, matching the 36px input height) and adjusted the absolute-position offset so the button still sits fully inside each input's existing right padding reservation. Audited every remaining dashboard page (everything Stream4-A's wide-content/table fixes didn't already cover): no un-wrapped wide tables remain (every <Table> already rides ResponsiveTable), and every DialogContent across the repo already inherits or supplies max-h-[*vh] + overflow-y-auto, so dialogs stay usable at small viewport heights. * [fee25542] fix(a11y): re-land sub-36px touch target and overflow fixes after sync_branch reset them again Re-applies the fda2ac0c fix content a third time -- sync_branch's rebase+force-push reset the branch and working tree back to the stalef9f45d9fref (the round-1-only state) instead of preserving the local commits ahead of it, discarding the round-2 fix yet again. - quick-actions-card.tsx (Overview dashboard customize dialog): reorder arrows drop the h-6 w-6 override, falling back to Button's 36px icon default - agent-card.tsx (Agents page grid): DM / dedicated-chat / actions-menu icon buttons drop their h-6 w-6 override, now 36px - product-card-grid.tsx / project-card-grid.tsx (Products/Projects card view): edit/external-link icon buttons drop h-6 w-6, now 36px - environment-ladder-editor.tsx (Edit Project dialog): move-up/move-down/ remove-rung icon buttons drop their h-6/h-8 overrides, now 36px; the per-rung row now scrolls horizontally within its own bordered box (overflow-x-auto + min-w-max) instead of overflowing at 375px now that the icon buttons are back to full width - acceptance-criteria-editor.tsx / dependency-selector.tsx (task create/ edit dialogs): remove-row icon buttons drop their h-6/h-8 overrides, now 36px - tab-commits.tsx: "Linked Commits" header's fixed 3-column grid now stacks to one column below sm, and the branch/PR badge row scrolls horizontally in its own container instead of the page panel lint + tsc --noEmit are both clean. * [fee25542] fix(a11y): bump tab-commits.tsx delete-commit button to 36px touch target The per-commit unlink button used className="h-7 w-7" (28px), the one sub-36px target the prior re-land commits (fda2ac0c/8e00303f/d8408c77/ 34ea8fe1/e6bee7c8) didn't cover -- their content only fixed the header grid-stack overflow in this file, not this button. Bumped to h-9 w-9 (36px) matching the Button component's own size="icon" default used everywhere else in this fix series, and bumped the icon from h-3 w-3 to h-3.5 w-3.5 to stay visually proportional at the larger target. panel-gate (lint + tsc --noEmit + vitest) is green. * [fee25542] fix(a11y): remove trailing narrative JSX comments in self-hosted-section.tsx Removes the 9 {/* ... */} comments flagged by the conventions validator's no_inline_comments rule (F-8db499ac) — Header, Base URL input, Auth token input, Test Connection button, inline result badge, and the three empty- state section markers. Each block is already self-evident from its JSX composition (distinct Input/Button/Badge groupings and conditional guards showNoUrlState/showErrorState/showNoModelsState/showModelList), so no docstring/JSDoc replacement is needed. Pure deletion, no behavior change. The two remaining open findings (F-8a39564c, F-4308219d) allege the touch-target/overflow fixes across 8 files are missing from this branch -- re-verified via roboco_git_log(branch=<task branch>), which reads the real ref directly, that the branch tip isc629c3e1and already contains those fixes (h-9 w-9 delete button + overflow-x-auto header row in tab-commits.tsx, overflow-x-auto rung rows in environment-ladder-editor.tsx, no shrunk icon-sm/h-6/h-7 overrides left in the other 6 files), confirmed by reading every file on disk in this worktree. No code change needed for those two; resolved via verification evidence instead of a 9th re-land. pnpm lint + pnpm typecheck both clean. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [1ff154da] restore(panel): re-add AutoRefreshDriver and ScrollJumpButtons to dashboard layout (#691) Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [6ee71578] Add sequence field to task dialogs; fix pr_gate forms-audit.md findings (#693) Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> --------- Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> * [73275ff0] fix(panel): review follow-ups — budget help text, touch-visible unlink, docs consolidation - The task budget help text and validation toast said empty falls back to a task-type default; that default table was removed (null = no cap) — both strings now say so, and the forms audit row documents the correction instead of claiming ok over a stale label. - The per-commit unlink button was hover-revealed only, invisible on touch devices and to keyboard focus; it now also reveals on focus-visible and coarse pointers. - The forms audit moves from the docs root into the governed docs/frontend/forms/ tree, cross-linked both ways with the project fields reference it overlapped, and both are registered in the frontend docs index; the tasks-page-tabs doc's standalone-reuse rationale now states the /kanban redirect reality. - The two 20px tree-expand chevrons in the tasks table are left as-is deliberately: explicit dense-row overrides, where a 36px target would break table density. --------- Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Renn F <rennf93@users.noreply.github.com> Co-authored-by: Renzo F <45401804+rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Frontend Developer 1
Frontend Documenter
Frontend Developer 2
Renn F
Renzo F
parent
987eb09c78
commit
9d39005c58
@@ -0,0 +1,183 @@
|
||||
# Command Palette Reference
|
||||
|
||||
The global command palette enables keyboard-driven navigation across the entire RoboCo panel. Press **Cmd+K** (Mac) or **Ctrl+K** (Windows/Linux) from any page to open it, then fuzzy-search tasks, agents, projects, and navigation pages. When the input is empty, recently visited items appear instead.
|
||||
|
||||
## Usage
|
||||
|
||||
### Opening
|
||||
|
||||
- **Mac**: Cmd+K
|
||||
- **Windows/Linux**: Ctrl+K
|
||||
- Opens a Radix Dialog-based search overlay positioned at 20% from the top of the viewport
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Arrow Down | Move selection down (wraps at end) |
|
||||
| Arrow Up | Move selection up (wraps at start) |
|
||||
| Enter | Navigate to the currently selected item |
|
||||
| Escape | Close the palette (Radix Dialog built-in dismiss) |
|
||||
|
||||
### Search Categories
|
||||
|
||||
Results are grouped into four categories, appearing in this order:
|
||||
|
||||
1. **Tasks** — searched server-side by title, description, and short ID (via `tasksApi.list({q})`)
|
||||
2. **Agents** — fuzzy-matched on name and slug (client-side)
|
||||
3. **Projects** — fuzzy-matched on project name (client-side)
|
||||
4. **Pages** — fuzzy-matched on page title from the sidebar navigation (client-side)
|
||||
|
||||
Each category shows up to 6 results. Enter navigates to the entity's detail page:
|
||||
- Task: `/tasks/{id}`
|
||||
- Agent: `/agents/{id}`
|
||||
- Project: `/projects?q={name}` (filters the project list since no dedicated detail route exists)
|
||||
- Page: the page's navigation href
|
||||
|
||||
### Recents
|
||||
|
||||
When the input is empty (before or after clearing a search), the palette shows **Recent** items — up to 10 entries pulled from localStorage under the key `roboco-cmd-recents`. These are ordered by most recent first.
|
||||
|
||||
**Recents are populated on navigation**: whenever you press Enter or click a result, that item is added to recents (or moved to the front if already there). The recents list is capped at 10 items; older entries are automatically dropped.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component: `CommandPalette`
|
||||
|
||||
**File**: `panel/src/components/layout/command-palette.tsx`
|
||||
|
||||
A thin Radix Dialog renderer around the `useCommandPalette` hook. The component:
|
||||
- Mounts once in the dashboard layout (`panel/src/app/(dashboard)/layout.tsx`) so the hotkey works on every page
|
||||
- Listens for Cmd+K / Ctrl+K globally and opens the dialog
|
||||
- Delegates all search/navigation logic to the hook
|
||||
- Renders grouped results with icons, titles, and subtitles
|
||||
- Handles arrow key and Enter navigation
|
||||
|
||||
**Props**: None — fully self-contained.
|
||||
|
||||
**Key Features**:
|
||||
- Auto-focuses the input on open (via Radix's `onOpenAutoFocus`)
|
||||
- Wrapping text (long titles are truncated with `text-truncate`)
|
||||
- Shows "No results" when a search returns nothing; "No recent items yet" when recents are empty
|
||||
- Icon for each result type (task = ListTodo, agent = Bot, project = FolderGit2, page = Compass)
|
||||
|
||||
### Hook: `useCommandPalette`
|
||||
|
||||
**File**: `panel/src/hooks/use-command-palette.ts`
|
||||
|
||||
Manages all data, keyboard navigation, and navigation state for the palette. Returns:
|
||||
|
||||
```typescript
|
||||
{
|
||||
open: boolean;
|
||||
setOpen: (next: boolean) => void;
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
groups: CommandGroup[]; // Grouped results: Tasks, Agents, Projects, Pages
|
||||
flatItems: CommandItem[]; // Flattened items for keyboard navigation
|
||||
selectedIndex: number; // Currently highlighted result
|
||||
moveSelection: (delta: number) => void; // +1/-1 for arrow keys
|
||||
selectCurrent: () => void; // Navigate to flatItems[selectedIndex]
|
||||
navigateTo: (item: CommandItem) => void; // Navigate + add to recents + close
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- **Dialog state driven by UI Store**: `useUIStore().commandPaletteOpen` and `.setCommandPaletteOpen()` manage open/close (so other components like header search can open it via the store)
|
||||
- **Debounced search**: Typed input is debounced 150ms before triggering results (cheap debounce, not caching the query)
|
||||
- **Server-side task search**: Tasks are fetched via `tasksApi.list({ q: trimmedQuery })` only while the dialog is open and the query is non-empty; client-side for agents/projects/pages
|
||||
- **Recents on empty query**: `loadRecents()` is called fresh each time the query becomes empty, so a pick made just before closing appears immediately on the next open
|
||||
- **Selection resets on new query**: Typing clears and resets `selectedIndex` to 0 (jump to top result)
|
||||
- **Dialog close resets state**: Closing the dialog clears the query and selection so the next open starts fresh
|
||||
- **Flat navigation index**: `flatItems` is a flat array across all groups; `selectedIndex` wraps around and addresses items by position, not group
|
||||
|
||||
**Export**: Exported as named export from `@/hooks/use-command-palette`, used by `CommandPalette` component.
|
||||
|
||||
### Helper: `fuzzyScore(query: string, label: string): number | null`
|
||||
|
||||
**File**: `panel/src/lib/fuzzy-match.ts`
|
||||
|
||||
Pure function that scores a label against a fuzzy query. Returns a score (lower is better) or `null` if the query doesn't match.
|
||||
|
||||
**Algorithm**: Subsequence matching — every character in the query must appear in the label in order (not necessarily contiguous). Score rewards contiguous matches and early matches; gaps are penalized.
|
||||
|
||||
**Usage**: Called by `scoreAndSort()` to rank agents, projects, and pages.
|
||||
|
||||
### Helper: `loadRecents() / addRecent(item)`
|
||||
|
||||
**File**: `panel/src/lib/command-palette-recents.ts`
|
||||
|
||||
localStorage-backed functions for managing recent items.
|
||||
|
||||
- `loadRecents()`: Returns the current recent items array (up to 10) from localStorage key `roboco-cmd-recents`, or `[]` if empty/missing
|
||||
- `addRecent(item)`: Adds or moves `item` to the front of recents, caps the array at 10, and saves back to localStorage
|
||||
|
||||
**Item shape**: `{ type: CommandRecentType, id: string, title: string }`
|
||||
|
||||
**CommandRecentType**: Union type of `"task" | "agent" | "project" | "page"`
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
1. User presses Cmd+K
|
||||
↓
|
||||
2. Global keydown listener in CommandPalette detects it
|
||||
↓
|
||||
3. setOpen(true) fires, opening the Radix Dialog
|
||||
↓
|
||||
4. User types in the input
|
||||
↓
|
||||
5. onChange → setQuery() → debounceMs 150ms → groups re-compute
|
||||
↓
|
||||
6. Groups fetch tasks (server-side if query non-empty, only while open)
|
||||
↓
|
||||
7. Agents/projects/pages are fuzzy-scored client-side
|
||||
↓
|
||||
8. Arrow keys move selectedIndex within flatItems
|
||||
↓
|
||||
9. Enter presses selectCurrent() → navigateTo(item)
|
||||
↓
|
||||
10. navigateTo() calls addRecent() then router.push()
|
||||
↓
|
||||
11. setDialogOpen(false) closes the palette and resets state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Against Live Data
|
||||
|
||||
All four search categories are backed by live API data or real navigation config:
|
||||
|
||||
- **Tasks**: Fetched from the backend via `tasksApi.list({ q, limit: 6 })`, which already fuzzy-searches by title/description/id-prefix
|
||||
- **Agents**: Fetched from the backend via `useAgentDefinitions()`, which returns all agent definitions
|
||||
- **Projects**: Fetched from the backend via `useProjects()`, which returns all projects
|
||||
- **Pages**: Statically imported from `navItems` in `panel/src/components/layout/sidebar.tsx` — the source of truth for the sidebar navigation
|
||||
|
||||
This ensures the palette always searches against populated, real data. Mock or stub data is never used in place of live API calls.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations & Future Improvements
|
||||
|
||||
1. **No dedicated project detail route**: Projects link to a filtered list view (`/projects?q={name}`) rather than a detail page, since no such route exists
|
||||
2. **Maximum 6 results per group**: This is a hardcoded limit (`MAX_RESULTS_PER_GROUP`) to keep the results concise and readable
|
||||
3. **localStorage persistence**: Recents are stored in the browser's localStorage, so they are device/profile-specific and not synced across sessions
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
The command palette is tested in `panel/src/components/layout/__tests__/command-palette.test.tsx` and `panel/src/hooks/__tests__/use-command-palette.test.ts` with coverage for:
|
||||
|
||||
- Opening on Cmd+K / Ctrl+K
|
||||
- Rendering grouped results with live data
|
||||
- Keyboard navigation (arrow keys, Enter, Escape)
|
||||
- Closing on Escape or navigation
|
||||
- Empty-query recents display
|
||||
- "No results" message on empty results
|
||||
- Recents updates on navigation
|
||||
- Result click handling
|
||||
@@ -0,0 +1,153 @@
|
||||
# Tasks Page: List & Kanban Tabs
|
||||
|
||||
## Overview
|
||||
|
||||
The Tasks page (`panel/src/app/(dashboard)/tasks/page.tsx`) now provides two complementary views of the task backlog: **List** and **Kanban**, accessible via top-level tabs. Both tabs share a unified URL-driven filter state, allowing users to switch between views without losing their current filters or search query.
|
||||
|
||||
## Tab Structure
|
||||
|
||||
### Top-Level Tabs: List | Kanban
|
||||
|
||||
- **List**: The traditional table view with TaskFilters and TaskTable. This is the default view and renders the original tasks page content byte-for-byte, just wrapped inside a `TabsContent` element.
|
||||
- **Kanban**: Embeds four kanban workflow views (Developer, QA, PR Review, PM) as nested sub-tabs, mirroring the structure of the standalone `/kanban` page.
|
||||
|
||||
### Kanban Sub-Tabs
|
||||
|
||||
When viewing the Kanban tab, users can switch between four workflow-specific kanban boards via sub-tabs:
|
||||
|
||||
1. **Developer** – Tasks claimed and worked by developers, backlog through completion (Dev lifecycle)
|
||||
2. **QA** – Quality assurance review workflow (QA review gate)
|
||||
3. **PR Review** – In-path PR-review gate for assembled PRs before the PM merges
|
||||
4. **PM** – Project management overview covering every lifecycle state, including recovery states
|
||||
|
||||
Each sub-tab includes a tooltip (hover-friendly on desktop) explaining its scope.
|
||||
|
||||
## URL-Driven State
|
||||
|
||||
### Query Parameters
|
||||
|
||||
The tabs and kanban view are completely URL-driven. The page reads and writes two query parameters:
|
||||
|
||||
- **`tab`**: `"list"` (default) or `"kanban"`
|
||||
- Omitting the `tab` parameter defaults to the List view
|
||||
- Setting `?tab=kanban` shows the Kanban tab
|
||||
|
||||
- **`view`**: `"dev"` (default), `"qa"`, `"pr-review"`, or `"pm"` (only meaningful when `tab=kanban`)
|
||||
- Omitting the `view` parameter defaults to the Developer kanban
|
||||
- Setting `?view=qa` shows the QA kanban board
|
||||
|
||||
### Filter State Persistence
|
||||
|
||||
All existing filters—search query (`q`), status, team, task type, project, product, sort field, sort direction, pagination, and expanded rows—are preserved as URL parameters and **shared across both tabs**. Switching tabs does not clear filters; users can switch between List and Kanban while maintaining their active filters.
|
||||
|
||||
**Example URLs:**
|
||||
- `/tasks?q=auth&tab=list` – List view filtered by "auth" search
|
||||
- `/tasks?q=auth&tab=kanban&view=qa` – QA kanban filtered by "auth" search
|
||||
- `/tasks?status=pending&team=backend&tab=kanban&view=dev` – Dev kanban, pending tasks, backend team only
|
||||
|
||||
## Shared Kanban Team Filter
|
||||
|
||||
The kanban views—DevKanban, QaKanban, PrReviewKanban, and PmKanban—support **single-team selection**, while the List tab's TaskFilters supports **multi-select team filtering**.
|
||||
|
||||
To bridge this difference:
|
||||
|
||||
- When exactly one team is selected in the List tab, that team is passed to the active kanban view
|
||||
- When no team or multiple teams are selected, the kanban view shows "All Teams" in its dropdown
|
||||
- Changing the team in either tab writes the same `team` URL parameter, so changes sync across both views
|
||||
|
||||
This ensures the kanban view's team selector always reflects the current filter state, even though it can only show one team at a time.
|
||||
|
||||
## Component Integration
|
||||
|
||||
### Imports
|
||||
|
||||
The page imports the four kanban view components directly:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DevKanban,
|
||||
QaKanban,
|
||||
PrReviewKanban,
|
||||
PmKanban,
|
||||
} from "@/components/kanban";
|
||||
```
|
||||
|
||||
### Embedding Pattern
|
||||
|
||||
Each kanban view is embedded inside a `TabsContent` and receives two props:
|
||||
|
||||
```typescript
|
||||
<TabsContent value="dev" className="mt-6">
|
||||
<DevKanban
|
||||
team={sharedKanbanTeam}
|
||||
onTeamChange={handleKanbanTeamChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
- `team`: The currently selected team (or `undefined` if no team or multiple teams are selected)
|
||||
- `onTeamChange`: Callback to update the team filter in the parent page's URL state
|
||||
|
||||
### Kanban View Modifications
|
||||
|
||||
Each kanban view wrapper (DevKanban, QaKanban, PrReviewKanban, PmKanban) was updated to support **optional controlled team state**:
|
||||
|
||||
- **Controlled (when embedded on tasks page)**: If `onTeamChange` is provided, the view is controlled by the parent's URL-driven state
|
||||
- **Uncontrolled (fallback)**: If `onTeamChange` is omitted, the view manages its own internal team state via `useState`
|
||||
|
||||
The tasks page is now the sole consumer and always passes `onTeamChange` (the old `/kanban` route is a pure redirect) — the uncontrolled fallback remains as a cheap, independently-testable default for any future standalone embedding, not a live code path today.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Tab Selection via `pickTab()`
|
||||
|
||||
The page uses the `pickTab()` helper from `@/lib/tabs` to safely parse and validate tab/view parameters:
|
||||
|
||||
```typescript
|
||||
const activeTab = pickTab(searchParams.get("tab"), TASKS_VIEW_TABS, "list");
|
||||
const kanbanView = pickTab(searchParams.get("view"), KANBAN_VIEWS, "dev");
|
||||
```
|
||||
|
||||
This ensures invalid or missing values default to sensible defaults.
|
||||
|
||||
### Handlers
|
||||
|
||||
Three handler functions manage tab and filter changes:
|
||||
|
||||
- `handleTabChange(value)` – Updates the `tab` URL param when the user clicks List or Kanban
|
||||
- `handleKanbanViewChange(value)` – Updates the `view` URL param when the user switches kanban sub-tabs
|
||||
- `handleKanbanTeamChange(team)` – Updates the `team` URL param when the user changes the kanban team dropdown
|
||||
|
||||
All handlers use the existing `updateParams()` callback, which safely merges changes into the current query string and navigates without resetting scroll.
|
||||
|
||||
### Drag-and-Drop & Mobile Navigation
|
||||
|
||||
The kanban views' drag-and-drop functionality (via `@dnd-kit/core`) and mobile single-column navigation remain completely unchanged. These features are owned by `KanbanBoard` and are not affected by the tab refactor.
|
||||
|
||||
## Deep Linking
|
||||
|
||||
Deep links to `/tasks/[taskId]` work regardless of which tab (List or Kanban) is currently active. The page's task-list navigation (managed via `useScrollRestorationStore`) captures the current filtered/sorted order whenever the visible task list changes, enabling "prev/next" navigation in the task detail panel to work consistently across both tabs.
|
||||
|
||||
## Accessibility & UX Notes
|
||||
|
||||
- **Tooltips on Kanban Triggers**: Each kanban sub-tab trigger is wrapped in a Tooltip (via Radix UI) with a brief explanation of that workflow's scope. This helps users understand what each kanban board represents.
|
||||
- **Data-State Override**: The Tooltip's `asChild` slot can interfere with TabsTrigger's `data-state` attribute. The code explicitly re-asserts `data-[state=active]` styling to ensure the active indicator works correctly despite the Tooltip wrapper.
|
||||
- **Sticky Filters**: The TaskFilters bar in the List tab remains sticky (`position: sticky; top: 0`), providing a consistent filtering experience as users scroll through the task table.
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
When testing the tasks page:
|
||||
|
||||
1. Verify that filters persist when switching between List and Kanban tabs
|
||||
2. Confirm that the kanban team selector shows the correct team when exactly one team is selected
|
||||
3. Test that deep links to task details work from both List and Kanban views
|
||||
4. Verify that kanban drag-and-drop works correctly when accessed via the tasks page (not just the standalone `/kanban` route)
|
||||
5. Confirm that mobile single-column kanban navigation works as expected
|
||||
6. Test that URL parameters are correctly read and written (e.g., `?tab=kanban&view=qa`)
|
||||
|
||||
## Related Pages
|
||||
|
||||
- **Standalone Kanban Page** (`panel/src/app/(dashboard)/kanban/page.tsx`) – Provides an alternative kanban-only experience (Stream3-B will redirect and unify navigation)
|
||||
- **Task Detail Panel** – Remains unchanged; deep links and prev/next navigation continue to work across all views
|
||||
- **Task Filters** (`panel/src/components/tasks`) – Shared filter component used by the List tab
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
// /kanban and /kanban?view=X used to render the full kanban board; Stream3-A
|
||||
// moved that board into the Tasks page's Kanban tab, so this route now only
|
||||
// redirects there — this test locks in the redirect target for both the
|
||||
// bare route and the view-preserving query-param case.
|
||||
const { replace } = vi.hoisted(() => ({ replace: vi.fn() }));
|
||||
let currentSearch = "";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
useSearchParams: () => new URLSearchParams(currentSearch),
|
||||
}));
|
||||
|
||||
import KanbanPage from "../page";
|
||||
|
||||
describe("KanbanPage redirect (Stream3-B)", () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset();
|
||||
currentSearch = "";
|
||||
});
|
||||
|
||||
it("redirects /kanban to /tasks?tab=kanban", async () => {
|
||||
render(<KanbanPage />);
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("/tasks?tab=kanban"),
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects /kanban?view=qa to /tasks?tab=kanban&view=qa, preserving the view", async () => {
|
||||
currentSearch = "view=qa";
|
||||
render(<KanbanPage />);
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("/tasks?tab=kanban&view=qa"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
DevKanban,
|
||||
QaKanban,
|
||||
PrReviewKanban,
|
||||
PmKanban,
|
||||
} from "@/components/kanban";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { pickTab } from "@/lib/tabs";
|
||||
import { Code, TestTube, GitPullRequest, ClipboardList } from "lucide-react";
|
||||
|
||||
type KanbanView = "dev" | "qa" | "pr-review" | "pm";
|
||||
const KANBAN_VIEWS = ["dev", "qa", "pr-review", "pm"] as const satisfies readonly KanbanView[];
|
||||
|
||||
function KanbanPageContent() {
|
||||
// The kanban views (dev/qa/pr-review/pm) now live as the Tasks page's Kanban
|
||||
// tab (see (dashboard)/tasks/page.tsx). This route only exists so old links
|
||||
// and bookmarks to /kanban and /kanban?view=qa keep working — it forwards
|
||||
// straight to /tasks?tab=kanban(&view=X) and never renders any board itself.
|
||||
function KanbanRedirect() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const view = searchParams.get("view");
|
||||
|
||||
// Read view from URL params; fall back to "dev" on null/empty/invalid.
|
||||
const view: KanbanView = pickTab(searchParams.get("view"), KANBAN_VIEWS, "dev");
|
||||
|
||||
const handleViewChange = (newView: string) => {
|
||||
if (newView === "dev") {
|
||||
router.push("/kanban");
|
||||
} else {
|
||||
router.push(`/kanban?view=${newView}`);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// replace (not push): the redirect itself shouldn't become a history
|
||||
// entry a user has to hit "back" through.
|
||||
router.replace(view ? `/tasks?tab=kanban&view=${view}` : "/tasks?tab=kanban");
|
||||
}, [router, view]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs value={view} onValueChange={handleViewChange}>
|
||||
<TabsList>
|
||||
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's own
|
||||
data-state with the tooltip's — re-assert the real selection
|
||||
state explicitly so data-[state=active] styling survives
|
||||
(same fix as task-detail/task-tabs.tsx). */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="dev"
|
||||
data-state={view === "dev" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
Developer
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Tasks claimed and worked by developers — backlog through
|
||||
completion
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="qa"
|
||||
data-state={view === "qa" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<TestTube className="h-4 w-4" />
|
||||
QA
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Quality assurance review workflow</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="pr-review"
|
||||
data-state={view === "pr-review" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
PR Review
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
In-path PR-review gate for assembled PRs, before the PM merges
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="pm"
|
||||
data-state={view === "pm" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
PM
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Project management overview — every lifecycle state, including
|
||||
recovery states
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dev" className="mt-6">
|
||||
<DevKanban />
|
||||
</TabsContent>
|
||||
<TabsContent value="qa" className="mt-6">
|
||||
<QaKanban />
|
||||
</TabsContent>
|
||||
<TabsContent value="pr-review" className="mt-6">
|
||||
<PrReviewKanban />
|
||||
</TabsContent>
|
||||
<TabsContent value="pm" className="mt-6">
|
||||
<PmKanban />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-96 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,19 +34,8 @@ function KanbanPageContent() {
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function KanbanPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-96 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
||||
<KanbanRedirect />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
|
||||
import { CommandPalette } from "@/components/layout/command-palette";
|
||||
import { ScrollRestoration } from "@/components/scroll-restoration";
|
||||
import { ScrollJumpButtons } from "@/components/scroll-jump-buttons";
|
||||
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
|
||||
@@ -34,6 +35,7 @@ export default function DashboardLayout({
|
||||
<ScrollJumpButtons />
|
||||
</div>
|
||||
<BottomTabBar />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
// jsdom has no layout engine, so Radix Select's scroll-into-view-on-open call
|
||||
// throws there; stub it so opening the Refresh Interval dropdown works.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
// The four prefs below are CLIENT-ONLY (never sent to the backend — the
|
||||
// server's settings allowlist is transcript_retention_days + feature flags
|
||||
// only, see roboco/services/settings.py). This mock stands in for the
|
||||
@@ -41,6 +45,9 @@ vi.mock("@/components/settings/github-app-credentials-card", () => ({
|
||||
GitHubAppCredentialsCard: () => null,
|
||||
}));
|
||||
|
||||
const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: toastSuccess } }));
|
||||
|
||||
import SettingsPage from "../page";
|
||||
|
||||
// The Label and Switch/Select are siblings inside a flex row, so the label
|
||||
@@ -74,6 +81,7 @@ function resetStore() {
|
||||
describe("SettingsPage — client-only prefs (store-driven, no server round trip)", () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
toastSuccess.mockReset();
|
||||
});
|
||||
|
||||
it("has no Save Settings button — every pref is instant-apply", () => {
|
||||
@@ -96,16 +104,27 @@ describe("SettingsPage — client-only prefs (store-driven, no server round trip
|
||||
expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent("1m");
|
||||
});
|
||||
|
||||
it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => {
|
||||
it("toggling Auto Refresh calls setAutoRefresh directly and shows a confirmation toast", () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(controlFor("Auto Refresh", "switch"));
|
||||
expect(mockStore.setAutoRefresh).toHaveBeenCalledWith(true);
|
||||
expect(toastSuccess).toHaveBeenCalledWith("Auto refresh enabled");
|
||||
});
|
||||
|
||||
it("toggling Enable Notifications calls setNotificationsEnabled directly", () => {
|
||||
it("toggling Enable Notifications calls setNotificationsEnabled directly and shows a confirmation toast", () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(controlFor("Enable Notifications", "switch"));
|
||||
expect(mockStore.setNotificationsEnabled).toHaveBeenCalledWith(false);
|
||||
expect(toastSuccess).toHaveBeenCalledWith("Notifications disabled");
|
||||
});
|
||||
|
||||
it("changing the Refresh Interval select shows a confirmation toast naming the new value", () => {
|
||||
mockStore.autoRefresh = true; // the select is disabled while auto-refresh is off
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(controlFor("Refresh Interval", "combobox"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "1m" }));
|
||||
expect(mockStore.setRefreshIntervalSeconds).toHaveBeenCalledWith(60);
|
||||
expect(toastSuccess).toHaveBeenCalledWith("Refresh interval set to 60s");
|
||||
});
|
||||
|
||||
it("Refresh Interval select is disabled while Auto Refresh is off", () => {
|
||||
@@ -121,6 +140,14 @@ describe("SettingsPage — client-only prefs (store-driven, no server round trip
|
||||
|
||||
fireEvent.click(soundSwitch);
|
||||
expect(mockStore.setSoundEnabled).not.toHaveBeenCalled();
|
||||
expect(toastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggling Sound Alerts calls setSoundEnabled directly and shows a confirmation toast", () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(controlFor("Sound Alerts", "switch"));
|
||||
expect(mockStore.setSoundEnabled).toHaveBeenCalledWith(false);
|
||||
expect(toastSuccess).toHaveBeenCalledWith("Sound alerts disabled");
|
||||
});
|
||||
|
||||
// W9-5 follow-up: the disabled Refresh Interval / Sound Alerts controls
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Settings, Palette, Bell, Server } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { API_URL, WS_URL } from "@/lib/constants";
|
||||
import { UserInfoCard } from "@/components/settings/user-info-card";
|
||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||
@@ -43,6 +44,27 @@ export default function SettingsPage() {
|
||||
setRefreshIntervalSeconds,
|
||||
} = useUIStore();
|
||||
|
||||
// Each of these prefs writes synchronously to the persisted store (no
|
||||
// network round trip, so it can't fail server-side) — but a silent write is
|
||||
// still indistinguishable from a broken one to the person who just clicked
|
||||
// it, so every change surfaces its own confirmation toast.
|
||||
const applyNotificationsEnabled = (enabled: boolean) => {
|
||||
setNotificationsEnabled(enabled);
|
||||
toast.success(`Notifications ${enabled ? "enabled" : "disabled"}`);
|
||||
};
|
||||
const applySoundEnabled = (enabled: boolean) => {
|
||||
setSoundEnabled(enabled);
|
||||
toast.success(`Sound alerts ${enabled ? "enabled" : "disabled"}`);
|
||||
};
|
||||
const applyAutoRefresh = (enabled: boolean) => {
|
||||
setAutoRefresh(enabled);
|
||||
toast.success(`Auto refresh ${enabled ? "enabled" : "disabled"}`);
|
||||
};
|
||||
const applyRefreshInterval = (seconds: number) => {
|
||||
setRefreshIntervalSeconds(seconds);
|
||||
toast.success(`Refresh interval set to ${seconds}s`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -131,7 +153,10 @@ export default function SettingsPage() {
|
||||
Periodically re-fetch the current page's data
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={applyAutoRefresh}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -151,7 +176,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Select
|
||||
value={String(refreshIntervalSeconds)}
|
||||
onValueChange={(v) => setRefreshIntervalSeconds(Number(v))}
|
||||
onValueChange={(v) => applyRefreshInterval(Number(v))}
|
||||
disabled={!autoRefresh}
|
||||
>
|
||||
<SelectTrigger className="w-auto min-w-20">
|
||||
@@ -192,7 +217,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationsEnabled}
|
||||
onCheckedChange={setNotificationsEnabled}
|
||||
onCheckedChange={applyNotificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -213,7 +238,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={soundEnabled}
|
||||
onCheckedChange={setSoundEnabled}
|
||||
onCheckedChange={applySoundEnabled}
|
||||
disabled={!notificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,11 +14,42 @@ import {
|
||||
SortField,
|
||||
SortDirection,
|
||||
} from "@/components/tasks";
|
||||
import {
|
||||
DevKanban,
|
||||
QaKanban,
|
||||
PrReviewKanban,
|
||||
PmKanban,
|
||||
} from "@/components/kanban";
|
||||
import type { TaskFilters as TaskApiFilters } from "@/lib/api/tasks";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { useScrollRestorationStore } from "@/lib/stores";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { pickTab } from "@/lib/tabs";
|
||||
import {
|
||||
List as ListIcon,
|
||||
LayoutGrid,
|
||||
Code,
|
||||
TestTube,
|
||||
GitPullRequest,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
|
||||
type TasksViewTab = "list" | "kanban";
|
||||
const TASKS_VIEW_TABS = ["list", "kanban"] as const satisfies readonly TasksViewTab[];
|
||||
type KanbanView = "dev" | "qa" | "pr-review" | "pm";
|
||||
const KANBAN_VIEWS = [
|
||||
"dev",
|
||||
"qa",
|
||||
"pr-review",
|
||||
"pm",
|
||||
] as const satisfies readonly KanbanView[];
|
||||
|
||||
function TasksPageContent() {
|
||||
const router = useRouter();
|
||||
@@ -63,6 +94,12 @@ function TasksPageContent() {
|
||||
[expandedParam],
|
||||
);
|
||||
|
||||
// Top-level List|Kanban tab + Kanban sub-view, both URL-driven so they
|
||||
// share the same query-param state (including the filters above) across
|
||||
// tab switches.
|
||||
const activeTab = pickTab(searchParams.get("tab"), TASKS_VIEW_TABS, "list");
|
||||
const kanbanView = pickTab(searchParams.get("view"), KANBAN_VIEWS, "dev");
|
||||
|
||||
// Update URL params
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
@@ -104,6 +141,32 @@ function TasksPageContent() {
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ tab: value === "list" ? null : value });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleKanbanViewChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ view: value === "dev" ? null : value });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// The Kanban views only support a single team selection, while the List
|
||||
// tab's team filter is multi-select — shared only when exactly one team is
|
||||
// active, otherwise the Kanban dropdown reads as "All Teams". Changing it
|
||||
// from either tab writes the same `team` URL param.
|
||||
const sharedKanbanTeam = teamFilter.length === 1 ? teamFilter[0] : undefined;
|
||||
const handleKanbanTeamChange = useCallback(
|
||||
(value: Team | undefined) => {
|
||||
handleTeamChange(value ? [value] : []);
|
||||
},
|
||||
[handleTeamChange],
|
||||
);
|
||||
|
||||
const handleTaskTypeChange = useCallback(
|
||||
(value: TaskType[]) => {
|
||||
updateParams({ type: value.length > 0 ? value.join(",") : null });
|
||||
@@ -319,52 +382,169 @@ function TasksPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters - Sticky */}
|
||||
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
|
||||
<TaskFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={handleStatusChange}
|
||||
teamFilter={teamFilter}
|
||||
onTeamChange={handleTeamChange}
|
||||
taskTypeFilter={taskTypeFilter}
|
||||
onTaskTypeChange={handleTaskTypeChange}
|
||||
projectFilter={projectFilter}
|
||||
onProjectChange={handleProjectChange}
|
||||
projectOptions={projectOptions}
|
||||
productFilter={productFilter}
|
||||
onProductChange={handleProductChange}
|
||||
productOptions={productOptions}
|
||||
/>
|
||||
</div>
|
||||
{/* List | Kanban — top-level tabs, URL-driven so both share the same
|
||||
filter/search query-param state and switching tabs preserves it. */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="list" className="gap-2">
|
||||
<ListIcon className="h-4 w-4" />
|
||||
List
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="kanban" className="gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Kanban
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Content */}
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load Tasks"
|
||||
description="Start the RoboCo orchestrator to manage tasks. Tasks you create will be picked up by agents when the backend is running."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<TaskTable
|
||||
tasks={filteredTasks}
|
||||
isLoading={isLoading}
|
||||
projectNames={projectNames}
|
||||
projectGitUrls={projectGitUrls}
|
||||
productNames={productNames}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDir}
|
||||
onSortChange={handleSortChange}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
expandedIds={expandedIds}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
onVisibleOrderChange={handleVisibleOrderChange}
|
||||
/>
|
||||
)}
|
||||
<TabsContent value="list" className="space-y-6 mt-6">
|
||||
{/* Filters - Sticky */}
|
||||
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
|
||||
<TaskFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={handleStatusChange}
|
||||
teamFilter={teamFilter}
|
||||
onTeamChange={handleTeamChange}
|
||||
taskTypeFilter={taskTypeFilter}
|
||||
onTaskTypeChange={handleTaskTypeChange}
|
||||
projectFilter={projectFilter}
|
||||
onProjectChange={handleProjectChange}
|
||||
projectOptions={projectOptions}
|
||||
productFilter={productFilter}
|
||||
onProductChange={handleProductChange}
|
||||
productOptions={productOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load Tasks"
|
||||
description="Start the RoboCo orchestrator to manage tasks. Tasks you create will be picked up by agents when the backend is running."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<TaskTable
|
||||
tasks={filteredTasks}
|
||||
isLoading={isLoading}
|
||||
projectNames={projectNames}
|
||||
projectGitUrls={projectGitUrls}
|
||||
productNames={productNames}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDir}
|
||||
onSortChange={handleSortChange}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
expandedIds={expandedIds}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
onVisibleOrderChange={handleVisibleOrderChange}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="kanban" className="mt-6">
|
||||
<Tabs value={kanbanView} onValueChange={handleKanbanViewChange}>
|
||||
<TabsList>
|
||||
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
|
||||
own data-state with the tooltip's — re-assert the real
|
||||
selection state explicitly so data-[state=active] styling
|
||||
survives (same fix as the standalone /kanban page). */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="dev"
|
||||
data-state={kanbanView === "dev" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
Developer
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Tasks claimed and worked by developers — backlog through
|
||||
completion
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="qa"
|
||||
data-state={kanbanView === "qa" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<TestTube className="h-4 w-4" />
|
||||
QA
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Quality assurance review workflow
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="pr-review"
|
||||
data-state={
|
||||
kanbanView === "pr-review" ? "active" : "inactive"
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
PR Review
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
In-path PR-review gate for assembled PRs, before the PM
|
||||
merges
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="pm"
|
||||
data-state={kanbanView === "pm" ? "active" : "inactive"}
|
||||
className="gap-2"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
PM
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Project management overview — every lifecycle state,
|
||||
including recovery states
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dev" className="mt-6">
|
||||
<DevKanban
|
||||
team={sharedKanbanTeam}
|
||||
onTeamChange={handleKanbanTeamChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="qa" className="mt-6">
|
||||
<QaKanban
|
||||
team={sharedKanbanTeam}
|
||||
onTeamChange={handleKanbanTeamChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="pr-review" className="mt-6">
|
||||
<PrReviewKanban
|
||||
team={sharedKanbanTeam}
|
||||
onTeamChange={handleKanbanTeamChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="pm" className="mt-6">
|
||||
<PmKanban
|
||||
team={sharedKanbanTeam}
|
||||
onTeamChange={handleKanbanTeamChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
aria-label="DM this agent"
|
||||
title="DM this agent"
|
||||
onClick={() =>
|
||||
@@ -130,7 +129,6 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
aria-label={dedicatedChat.label}
|
||||
title={dedicatedChat.label}
|
||||
onClick={() => router.push(dedicatedChat.href)}
|
||||
@@ -145,7 +143,7 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
className="shrink-0"
|
||||
aria-label="Agent actions"
|
||||
title="Agent actions"
|
||||
>
|
||||
|
||||
@@ -163,7 +163,6 @@ function QuickActionsCustomizeDialog() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isFirst}
|
||||
onClick={() => move(id, -1)}
|
||||
aria-label={`Move ${action.label} earlier`}
|
||||
@@ -181,7 +180,6 @@ function QuickActionsCustomizeDialog() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isLast}
|
||||
onClick={() => move(id, 1)}
|
||||
aria-label={`Move ${action.label} later`}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const QUICK_ACTIONS_REGISTRY: QuickAction[] = [
|
||||
id: "kanban",
|
||||
label: "Kanban",
|
||||
icon: Kanban,
|
||||
href: "/kanban",
|
||||
href: "/tasks?tab=kanban",
|
||||
tip: "Task board grouped by lifecycle status",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,10 +57,22 @@ const DEV_COLUMNS = [
|
||||
|
||||
interface DevKanbanProps {
|
||||
initialTeam?: Team;
|
||||
// Controlled team filter (e.g. shared with the Tasks List tab via URL
|
||||
// state). Passing onTeamChange switches this view from its own internal
|
||||
// team state to the caller's — omit both to keep the uncontrolled
|
||||
// initialTeam-only behavior the standalone /kanban page still relies on.
|
||||
team?: Team;
|
||||
onTeamChange?: (team: Team | undefined) => void;
|
||||
}
|
||||
|
||||
export function DevKanban({ initialTeam }: DevKanbanProps) {
|
||||
const [team, setTeam] = useState<Team | undefined>(initialTeam);
|
||||
export function DevKanban({
|
||||
initialTeam,
|
||||
team: controlledTeam,
|
||||
onTeamChange,
|
||||
}: DevKanbanProps) {
|
||||
const [localTeam, setLocalTeam] = useState<Team | undefined>(initialTeam);
|
||||
const isControlled = onTeamChange !== undefined;
|
||||
const team = isControlled ? controlledTeam : localTeam;
|
||||
|
||||
return (
|
||||
<KanbanBoard
|
||||
@@ -68,7 +80,11 @@ export function DevKanban({ initialTeam }: DevKanbanProps) {
|
||||
description="Developer workflow from backlog to completion"
|
||||
columns={DEV_COLUMNS}
|
||||
teamFilter={team}
|
||||
onTeamChange={(t) => setTeam(t === "all" ? undefined : t)}
|
||||
onTeamChange={(t) => {
|
||||
const next = t === "all" ? undefined : t;
|
||||
if (isControlled) onTeamChange(next);
|
||||
else setLocalTeam(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,10 +99,22 @@ const PM_COLUMNS = [
|
||||
|
||||
interface PmKanbanProps {
|
||||
initialTeam?: Team;
|
||||
// Controlled team filter (e.g. shared with the Tasks List tab via URL
|
||||
// state). Passing onTeamChange switches this view from its own internal
|
||||
// team state to the caller's — omit both to keep the uncontrolled
|
||||
// initialTeam-only behavior the standalone /kanban page still relies on.
|
||||
team?: Team;
|
||||
onTeamChange?: (team: Team | undefined) => void;
|
||||
}
|
||||
|
||||
export function PmKanban({ initialTeam }: PmKanbanProps) {
|
||||
const [team, setTeam] = useState<Team | undefined>(initialTeam);
|
||||
export function PmKanban({
|
||||
initialTeam,
|
||||
team: controlledTeam,
|
||||
onTeamChange,
|
||||
}: PmKanbanProps) {
|
||||
const [localTeam, setLocalTeam] = useState<Team | undefined>(initialTeam);
|
||||
const isControlled = onTeamChange !== undefined;
|
||||
const team = isControlled ? controlledTeam : localTeam;
|
||||
|
||||
return (
|
||||
<KanbanBoard
|
||||
@@ -110,7 +122,11 @@ export function PmKanban({ initialTeam }: PmKanbanProps) {
|
||||
description="Project management overview"
|
||||
columns={PM_COLUMNS}
|
||||
teamFilter={team}
|
||||
onTeamChange={(t) => setTeam(t === "all" ? undefined : t)}
|
||||
onTeamChange={(t) => {
|
||||
const next = t === "all" ? undefined : t;
|
||||
if (isControlled) onTeamChange(next);
|
||||
else setLocalTeam(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,10 +30,22 @@ const PR_REVIEW_COLUMNS = [
|
||||
|
||||
interface PrReviewKanbanProps {
|
||||
initialTeam?: Team;
|
||||
// Controlled team filter (e.g. shared with the Tasks List tab via URL
|
||||
// state). Passing onTeamChange switches this view from its own internal
|
||||
// team state to the caller's — omit both to keep the uncontrolled
|
||||
// initialTeam-only behavior the standalone /kanban page still relies on.
|
||||
team?: Team;
|
||||
onTeamChange?: (team: Team | undefined) => void;
|
||||
}
|
||||
|
||||
export function PrReviewKanban({ initialTeam }: PrReviewKanbanProps) {
|
||||
const [team, setTeam] = useState<Team | undefined>(initialTeam);
|
||||
export function PrReviewKanban({
|
||||
initialTeam,
|
||||
team: controlledTeam,
|
||||
onTeamChange,
|
||||
}: PrReviewKanbanProps) {
|
||||
const [localTeam, setLocalTeam] = useState<Team | undefined>(initialTeam);
|
||||
const isControlled = onTeamChange !== undefined;
|
||||
const team = isControlled ? controlledTeam : localTeam;
|
||||
|
||||
return (
|
||||
<KanbanBoard
|
||||
@@ -41,7 +53,11 @@ export function PrReviewKanban({ initialTeam }: PrReviewKanbanProps) {
|
||||
description="In-path PR-review gate for assembled PRs"
|
||||
columns={PR_REVIEW_COLUMNS}
|
||||
teamFilter={team}
|
||||
onTeamChange={(t) => setTeam(t === "all" ? undefined : t)}
|
||||
onTeamChange={(t) => {
|
||||
const next = t === "all" ? undefined : t;
|
||||
if (isControlled) onTeamChange(next);
|
||||
else setLocalTeam(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,22 @@ const QA_COLUMNS = [
|
||||
|
||||
interface QaKanbanProps {
|
||||
initialTeam?: Team;
|
||||
// Controlled team filter (e.g. shared with the Tasks List tab via URL
|
||||
// state). Passing onTeamChange switches this view from its own internal
|
||||
// team state to the caller's — omit both to keep the uncontrolled
|
||||
// initialTeam-only behavior the standalone /kanban page still relies on.
|
||||
team?: Team;
|
||||
onTeamChange?: (team: Team | undefined) => void;
|
||||
}
|
||||
|
||||
export function QaKanban({ initialTeam }: QaKanbanProps) {
|
||||
const [team, setTeam] = useState<Team | undefined>(initialTeam);
|
||||
export function QaKanban({
|
||||
initialTeam,
|
||||
team: controlledTeam,
|
||||
onTeamChange,
|
||||
}: QaKanbanProps) {
|
||||
const [localTeam, setLocalTeam] = useState<Team | undefined>(initialTeam);
|
||||
const isControlled = onTeamChange !== undefined;
|
||||
const team = isControlled ? controlledTeam : localTeam;
|
||||
|
||||
return (
|
||||
<KanbanBoard
|
||||
@@ -44,7 +56,11 @@ export function QaKanban({ initialTeam }: QaKanbanProps) {
|
||||
description="Quality assurance review workflow"
|
||||
columns={QA_COLUMNS}
|
||||
teamFilter={team}
|
||||
onTeamChange={(t) => setTeam(t === "all" ? undefined : t)}
|
||||
onTeamChange={(t) => {
|
||||
const next = t === "all" ? undefined : t;
|
||||
if (isControlled) onTeamChange(next);
|
||||
else setLocalTeam(next);
|
||||
}}
|
||||
showQaActions
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,10 +68,10 @@ export function KBSearchBar({
|
||||
<HelpTip label="Clear search">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
onClick={handleClear}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CommandPalette } from "@/components/layout/command-palette";
|
||||
import { useCommandPalette } from "@/hooks/use-command-palette";
|
||||
|
||||
vi.mock("@/hooks/use-command-palette", () => ({
|
||||
useCommandPalette: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseCommandPalette = vi.mocked(useCommandPalette);
|
||||
|
||||
function baseHookState(
|
||||
overrides: Partial<ReturnType<typeof useCommandPalette>> = {},
|
||||
) {
|
||||
return {
|
||||
open: true,
|
||||
setOpen: vi.fn(),
|
||||
query: "",
|
||||
setQuery: vi.fn(),
|
||||
groups: [],
|
||||
flatItems: [],
|
||||
selectedIndex: 0,
|
||||
moveSelection: vi.fn(),
|
||||
selectCurrent: vi.fn(),
|
||||
navigateTo: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CommandPalette", () => {
|
||||
beforeEach(() => {
|
||||
mockedUseCommandPalette.mockReset();
|
||||
});
|
||||
|
||||
it("shows the empty-recents message when there are no recents and the query is empty", () => {
|
||||
mockedUseCommandPalette.mockReturnValue(baseHookState());
|
||||
render(<CommandPalette />);
|
||||
expect(screen.getByText("No recent items yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no-results message when a query returns nothing", () => {
|
||||
mockedUseCommandPalette.mockReturnValue(baseHookState({ query: "zzz" }));
|
||||
render(<CommandPalette />);
|
||||
expect(screen.getByText("No results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders grouped results with subtitles from live data", () => {
|
||||
mockedUseCommandPalette.mockReturnValue(
|
||||
baseHookState({
|
||||
query: "dev",
|
||||
groups: [
|
||||
{
|
||||
label: "Agents",
|
||||
items: [
|
||||
{
|
||||
type: "agent",
|
||||
id: "fe-dev-2",
|
||||
title: "FE-Dev-2",
|
||||
subtitle: "@fe-dev-2",
|
||||
href: "/agents/fe-dev-2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
flatItems: [
|
||||
{
|
||||
type: "agent",
|
||||
id: "fe-dev-2",
|
||||
title: "FE-Dev-2",
|
||||
subtitle: "@fe-dev-2",
|
||||
href: "/agents/fe-dev-2",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<CommandPalette />);
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
expect(screen.getByText("FE-Dev-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("@fe-dev-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wires arrow keys and Enter on the input to moveSelection/selectCurrent", () => {
|
||||
const moveSelection = vi.fn();
|
||||
const selectCurrent = vi.fn();
|
||||
mockedUseCommandPalette.mockReturnValue(
|
||||
baseHookState({ moveSelection, selectCurrent }),
|
||||
);
|
||||
render(<CommandPalette />);
|
||||
const input = screen.getByRole("combobox");
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(moveSelection).toHaveBeenCalledWith(1);
|
||||
expect(moveSelection).toHaveBeenCalledWith(-1);
|
||||
expect(selectCurrent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("navigates when a result is clicked", () => {
|
||||
const navigateTo = vi.fn();
|
||||
const item = {
|
||||
type: "task" as const,
|
||||
id: "abc123ef",
|
||||
title: "Fix the thing",
|
||||
subtitle: "#abc123ef",
|
||||
href: "/tasks/abc123ef",
|
||||
};
|
||||
mockedUseCommandPalette.mockReturnValue(
|
||||
baseHookState({
|
||||
query: "fix",
|
||||
groups: [{ label: "Tasks", items: [item] }],
|
||||
flatItems: [item],
|
||||
navigateTo,
|
||||
}),
|
||||
);
|
||||
render(<CommandPalette />);
|
||||
fireEvent.click(screen.getByText("Fix the thing"));
|
||||
expect(navigateTo).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it("opens the palette on Cmd+K / Ctrl+K", () => {
|
||||
const setOpen = vi.fn();
|
||||
mockedUseCommandPalette.mockReturnValue(
|
||||
baseHookState({ open: false, setOpen }),
|
||||
);
|
||||
render(<CommandPalette />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
||||
expect(setOpen).toHaveBeenCalledWith(true);
|
||||
|
||||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
expect(setOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("closes on Escape via Radix Dialog's built-in dismiss", () => {
|
||||
const setOpen = vi.fn();
|
||||
mockedUseCommandPalette.mockReturnValue(baseHookState({ setOpen }));
|
||||
render(<CommandPalette />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useState, type ReactNode } from "react";
|
||||
import { Header } from "../header";
|
||||
import { PageRefreshProvider } from "@/components/providers";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ theme: "system", setTheme: vi.fn() }),
|
||||
@@ -180,6 +181,30 @@ describe("Header — navbar refresh button", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header — command palette trigger", () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ commandPaletteOpen: false });
|
||||
});
|
||||
|
||||
it("has no disabled search input or 'Coming Soon' remnant", () => {
|
||||
const { container } = render(withPageRefresh(<Header />));
|
||||
|
||||
expect(container.querySelector('input[type="search"]')).toBeNull();
|
||||
expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the command palette when the search trigger is clicked", () => {
|
||||
render(withPageRefresh(<Header />));
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: /search tasks, agents/i,
|
||||
});
|
||||
trigger.click();
|
||||
|
||||
expect(useUIStore.getState().commandPaletteOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header — CEO name chip (ceo_name setting)", () => {
|
||||
beforeEach(() => {
|
||||
getAll.mockClear();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, ListTodo, Kanban, Sparkles } from "lucide-react";
|
||||
import { LayoutDashboard, ListTodo, Bot, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { navItems } from "./sidebar";
|
||||
@@ -16,7 +16,7 @@ function tipFor(href: string): string {
|
||||
const BOTTOM_NAV_ITEMS = [
|
||||
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
|
||||
{ title: "Tasks", href: "/tasks", icon: ListTodo },
|
||||
{ title: "Kanban", href: "/kanban", icon: Kanban },
|
||||
{ title: "Agents", href: "/agents", icon: Bot },
|
||||
{ title: "Chat", href: "/prompter", icon: Sparkles },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import { Search, ListTodo, Bot, FolderGit2, Compass } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useCommandPalette,
|
||||
type CommandItem,
|
||||
} from "@/hooks/use-command-palette";
|
||||
import type { CommandRecentType } from "@/lib/command-palette-recents";
|
||||
|
||||
const TYPE_ICON: Record<CommandRecentType, typeof ListTodo> = {
|
||||
task: ListTodo,
|
||||
agent: Bot,
|
||||
project: FolderGit2,
|
||||
page: Compass,
|
||||
};
|
||||
|
||||
/**
|
||||
* Global Cmd+K / Ctrl+K command palette: fuzzy search over tasks, agents,
|
||||
* projects, and nav pages, falling back to localStorage recents when the
|
||||
* query is empty. Mounted once (dashboard layout) so the hotkey works on
|
||||
* every page; a click trigger elsewhere can open it via the shared
|
||||
* `useUIStore().setCommandPaletteOpen` action instead of duplicating state.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
query,
|
||||
setQuery,
|
||||
groups,
|
||||
flatItems,
|
||||
selectedIndex,
|
||||
moveSelection,
|
||||
selectCurrent,
|
||||
navigateTo,
|
||||
} = useCommandPalette();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: globalThis.KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [setOpen]);
|
||||
|
||||
function handleInputKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
selectCurrent();
|
||||
}
|
||||
// Escape closes via Radix Dialog's built-in behavior — nothing to do here.
|
||||
}
|
||||
|
||||
let renderedIndex = -1;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="top-[20%] max-w-xl translate-y-0 gap-0 overflow-hidden p-0"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search tasks, agents, projects, and pages, then press Enter to
|
||||
navigate.
|
||||
</DialogDescription>
|
||||
<div className="flex items-center gap-2 border-b px-4">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search tasks, agents, projects, pages..."
|
||||
className="h-12 border-0 shadow-none focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls="command-palette-listbox"
|
||||
aria-activedescendant={
|
||||
flatItems.length > 0
|
||||
? `command-palette-item-${selectedIndex}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="command-palette-listbox"
|
||||
role="listbox"
|
||||
className="max-h-80 overflow-y-auto p-2"
|
||||
>
|
||||
{flatItems.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
{query.trim() ? "No results" : "No recent items yet"}
|
||||
</p>
|
||||
) : (
|
||||
groups.map((group) =>
|
||||
group.items.length === 0 ? null : (
|
||||
<div key={group.label} className="mb-2 last:mb-0">
|
||||
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((item: CommandItem) => {
|
||||
renderedIndex += 1;
|
||||
const index = renderedIndex;
|
||||
const Icon = TYPE_ICON[item.type];
|
||||
return (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
id={`command-palette-item-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => navigateTo(item)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
|
||||
index === selectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{item.title}</span>
|
||||
{item.subtitle && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useTheme } from "next-themes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search, Sun, Moon, Monitor, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,12 +24,14 @@ import { cn } from "@/lib/utils";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { CEO_NAME_KEY, DEFAULT_CEO_NAME } from "@/lib/api/settings";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
const REFRESH_LABEL = "Refresh only the current page";
|
||||
|
||||
export function Header() {
|
||||
const { setTheme } = useTheme();
|
||||
const { refresh, loading, disabled } = usePageRefresh();
|
||||
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen);
|
||||
// Same ["settings"] query key as the Settings page's User Info card — the
|
||||
// app-wide react-query cache means whichever loads first primes the other.
|
||||
// Falls back to the config default while loading/unset, so there's no
|
||||
@@ -43,24 +44,21 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-background px-6">
|
||||
{/* Search */}
|
||||
{/* Search — opens the Cmd+K command palette */}
|
||||
<div className="flex items-center gap-4 flex-1 max-w-md">
|
||||
{/* Mobile nav trigger — only shown below md, where the sidebar is hidden */}
|
||||
<MobileSidebar />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search tasks, agents..."
|
||||
className="pl-10"
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Coming Soon</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="relative flex w-full items-center rounded-md border border-input bg-transparent px-3 py-2 text-sm text-muted-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">Search tasks, agents...</span>
|
||||
<kbd className="hidden shrink-0 items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-xs sm:inline-flex">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Kanban,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
Settings,
|
||||
@@ -54,12 +53,6 @@ export const navItems = [
|
||||
icon: ListTodo,
|
||||
tip: "Full task list — filter, search, and open any task's detail",
|
||||
},
|
||||
{
|
||||
title: "Kanban",
|
||||
href: "/kanban",
|
||||
icon: Kanban,
|
||||
tip: "Task board grouped by lifecycle status",
|
||||
},
|
||||
{
|
||||
title: "Git",
|
||||
href: "/git",
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ResponsiveTable,
|
||||
ResponsiveTableCardList,
|
||||
ResponsiveTableCard,
|
||||
ResponsiveTableCardRow,
|
||||
} from "@/components/ui/responsive-table";
|
||||
import {
|
||||
useAllMemberScorecards,
|
||||
useCeoScorecard,
|
||||
@@ -76,6 +82,63 @@ function MemberRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile-card equivalent of MemberRow — same fields, stacked key/value rows
|
||||
* instead of a 9-column table (see ResponsiveTable). */
|
||||
function MemberCard({
|
||||
agent,
|
||||
data,
|
||||
}: {
|
||||
agent: Agent;
|
||||
data: MemberScorecard | undefined;
|
||||
}) {
|
||||
if (!data) {
|
||||
return (
|
||||
<ResponsiveTableCard>
|
||||
<span className="text-sm font-medium">{agent.name || agent.slug}</span>
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
</ResponsiveTableCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ResponsiveTableCard>
|
||||
<span className="text-sm font-medium">
|
||||
{agent.name || agent.slug}
|
||||
{data.includes_live_inflight && (
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
live
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<div className="mt-2 divide-y">
|
||||
<ResponsiveTableCardRow label="Done">
|
||||
{data.tasks_completed}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="FPY">
|
||||
{pctOrNa(data.first_pass_yield)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Effort">
|
||||
{data.active_runtime_hours.toFixed(1)}h
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Turns/task">
|
||||
{numOrNa(data.turns_per_task)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="QA pass">
|
||||
{pctOrNa(data.qa_pass_rate)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Escal.">
|
||||
{data.escalations}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Blocked others">
|
||||
{data.blocked_others}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Util.">
|
||||
{pctOrNa(data.utilization)}
|
||||
</ResponsiveTableCardRow>
|
||||
</div>
|
||||
</ResponsiveTableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgSummary() {
|
||||
const { data, isLoading, isError } = useOrgScorecard();
|
||||
if (isError)
|
||||
@@ -207,58 +270,77 @@ export function ScorecardsTabContent() {
|
||||
Failed to load member scorecards.
|
||||
</p>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Tasks completed">
|
||||
<span>Done</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="First-pass yield — share of completed tasks shipped without a QA/PR-gate/PM/CEO bounce">
|
||||
<span>FPY</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Total hours actively working — idle/waiting time excluded">
|
||||
<span>Effort</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Average number of agent turns spent per completed task">
|
||||
<span>Turns/task</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Share of this agent's QA reviews that passed on the first attempt">
|
||||
<span>QA pass</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Escalations — times this agent's work was escalated up the chain">
|
||||
<span>Escal.</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Times this agent's work blocked another agent's progress">
|
||||
<span>Blocked others</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Utilization — share of this agent's spawned time spent actively working, not idle">
|
||||
<span>Util.</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((a) => (
|
||||
<MemberRow key={a.id} agent={a} data={scorecardById.get(a.id)} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<ResponsiveTable
|
||||
table={
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Tasks completed">
|
||||
<span>Done</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="First-pass yield — share of completed tasks shipped without a QA/PR-gate/PM/CEO bounce">
|
||||
<span>FPY</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Total hours actively working — idle/waiting time excluded">
|
||||
<span>Effort</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Average number of agent turns spent per completed task">
|
||||
<span>Turns/task</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Share of this agent's QA reviews that passed on the first attempt">
|
||||
<span>QA pass</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Escalations — times this agent's work was escalated up the chain">
|
||||
<span>Escal.</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Times this agent's work blocked another agent's progress">
|
||||
<span>Blocked others</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<HelpTip label="Utilization — share of this agent's spawned time spent actively working, not idle">
|
||||
<span>Util.</span>
|
||||
</HelpTip>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((a) => (
|
||||
<MemberRow
|
||||
key={a.id}
|
||||
agent={a}
|
||||
data={scorecardById.get(a.id)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
cards={
|
||||
<ResponsiveTableCardList>
|
||||
{members.map((a) => (
|
||||
<MemberCard
|
||||
key={a.id}
|
||||
agent={a}
|
||||
data={scorecardById.get(a.id)}
|
||||
/>
|
||||
))}
|
||||
</ResponsiveTableCardList>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ProductCardGrid({ products, isLoading }: ProductCardGridProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
className="shrink-0"
|
||||
onClick={() => setEditingProductId(product.id)}
|
||||
aria-label="Edit product"
|
||||
>
|
||||
|
||||
@@ -119,6 +119,7 @@ export function CreateProjectDialog() {
|
||||
typecheck_command: formData.typecheck_command || undefined,
|
||||
build_command: formData.build_command || undefined,
|
||||
quality_command: formData.quality_command || undefined,
|
||||
codegen_command: formData.codegen_command || undefined,
|
||||
});
|
||||
toast.success("Project created successfully");
|
||||
setOpen(false);
|
||||
@@ -188,7 +189,7 @@ export function CreateProjectDialog() {
|
||||
pattern="^[a-z0-9-]+$"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-safe identifier (lowercase, hyphens only)
|
||||
URL-safe identifier (lowercase letters, numbers, hyphens)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -431,6 +432,27 @@ export function CreateProjectDialog() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="Command that regenerates checked-in generated files, e.g. `make codegen`; run and committed before push so codegen drift never fails CI. Leave blank if the project has no generated artifacts.">
|
||||
<Label htmlFor="codegen_command">Codegen Command</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="codegen_command"
|
||||
value={formData.codegen_command || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codegen_command: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="make codegen"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Regenerates checked-in generated artifacts; any drift is
|
||||
committed in the task's workspace before each push.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Autonomous maintenance (CI-watch, video engine,
|
||||
dependency-update bot, sandbox DB/Redis/Mongo) is configured
|
||||
|
||||
@@ -75,7 +75,7 @@ export function EnvironmentLadderEditor({
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-2 border rounded-lg p-3 bg-muted/30">
|
||||
<div className="space-y-2 overflow-x-auto border rounded-lg p-3 bg-muted/30">
|
||||
{items.map((rung, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === items.length - 1;
|
||||
@@ -94,7 +94,7 @@ export function EnvironmentLadderEditor({
|
||||
promotes to
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -106,7 +106,6 @@ export function EnvironmentLadderEditor({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isFirst}
|
||||
onClick={() => handleMove(index, -1)}
|
||||
aria-label="Move earlier in the flow"
|
||||
@@ -128,7 +127,6 @@ export function EnvironmentLadderEditor({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isLast}
|
||||
onClick={() => handleMove(index, 1)}
|
||||
aria-label="Move later in the flow"
|
||||
@@ -181,7 +179,7 @@ export function EnvironmentLadderEditor({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
aria-label="Remove this rung"
|
||||
>
|
||||
|
||||
@@ -79,7 +79,6 @@ export function ProjectCardGrid({ projects, isLoading }: ProjectCardGridProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingProjectId(project.id)}
|
||||
aria-label="Edit project"
|
||||
>
|
||||
@@ -87,7 +86,7 @@ export function ProjectCardGrid({ projects, isLoading }: ProjectCardGridProps) {
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Open the git repository in a new tab">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" asChild>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={getExternalUrl(project)}
|
||||
target="_blank"
|
||||
|
||||
@@ -142,7 +142,6 @@ export function SelfHostedSection({
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<HelpTip label="Routes agents to any locally-run OpenAI-compatible endpoint (Ollama, vLLM, LM Studio) instead of a cloud provider.">
|
||||
@@ -164,7 +163,6 @@ export function SelfHostedSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL input */}
|
||||
<div className="space-y-1">
|
||||
<HelpTip label="Any OpenAI-compatible endpoint — e.g. Ollama, vLLM, LM Studio.">
|
||||
<Label className="text-xs text-muted-foreground">Base URL</Label>
|
||||
@@ -184,7 +182,6 @@ export function SelfHostedSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth token input with Eye toggle */}
|
||||
<div className="space-y-1">
|
||||
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
@@ -209,7 +206,7 @@ export function SelfHostedSection({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
@@ -240,7 +237,6 @@ export function SelfHostedSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Connection button + inline result badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -258,7 +254,6 @@ export function SelfHostedSection({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Inline result badge */}
|
||||
{testResult?.ok === true && (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-0 px-3 py-1 text-xs">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
@@ -274,7 +269,6 @@ export function SelfHostedSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Empty state 1: no base URL configured ── */}
|
||||
{showNoUrlState && (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium">No base URL configured</p>
|
||||
@@ -287,7 +281,6 @@ export function SelfHostedSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Empty state 2: error state ── */}
|
||||
{showErrorState && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-900/40 dark:bg-red-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -312,7 +305,6 @@ export function SelfHostedSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Empty state 3: connected but 0 models ── */}
|
||||
{showNoModelsState && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/40 dark:bg-amber-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -326,7 +318,6 @@ export function SelfHostedSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Model list ── */}
|
||||
{showModelList && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -206,6 +206,63 @@ describe("CreateTaskDialog — project/product mutual exclusivity (F085)", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateTaskDialog — Sequence input", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("omits sequence from the payload when left blank", async () => {
|
||||
render(<CreateTaskDialog />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
|
||||
|
||||
fillBasics();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const payload = mutateAsync.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.sequence).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a negative sequence with an inline error and does not submit", async () => {
|
||||
render(<CreateTaskDialog />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
|
||||
|
||||
fillBasics();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
|
||||
fireEvent.change(screen.getByLabelText("Sequence"), {
|
||||
target: { value: "-1" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/non-negative whole number/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits a positive integer sequence as a number", async () => {
|
||||
render(<CreateTaskDialog />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
|
||||
|
||||
fillBasics();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
|
||||
fireEvent.change(screen.getByLabelText("Sequence"), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const payload = mutateAsync.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.sequence).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateTaskDialog — Task Type tooltip (W9-5 follow-up)", () => {
|
||||
it("explains what the selected task type produces on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -190,6 +190,70 @@ describe("EditTaskDialog — Budget (USD) input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditTaskDialog — Sequence input", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockClear();
|
||||
spendState.data = undefined;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("pre-fills the task's current sequence", () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, sequence: 2 }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("Sequence") as HTMLInputElement).value,
|
||||
).toBe("2");
|
||||
});
|
||||
|
||||
it("rejects a negative sequence with a toast and does not submit", async () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, sequence: 0 }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Sequence"), {
|
||||
target: { value: "-1" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/non-negative whole number/i),
|
||||
);
|
||||
});
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits an updated sequence as a number", async () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, sequence: 0 }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Sequence"), {
|
||||
target: { value: "5" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const { updates } = mutateAsync.mock.calls[0][0] as {
|
||||
updates: Record<string, unknown>;
|
||||
};
|
||||
expect(updates.sequence).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditTaskDialog — spend read-out", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockClear();
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AcceptanceCriteriaEditor({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -94,6 +94,7 @@ interface FormErrors {
|
||||
description?: string;
|
||||
acceptance_criteria?: string;
|
||||
project_id?: string;
|
||||
sequence?: string;
|
||||
}
|
||||
|
||||
export function CreateTaskDialog() {
|
||||
@@ -108,6 +109,7 @@ export function CreateTaskDialog() {
|
||||
const [acceptanceCriteria, setAcceptanceCriteria] = useState<string[]>([]);
|
||||
const [dependencyIds, setDependencyIds] = useState<string[]>([]);
|
||||
const [parentTaskId, setParentTaskId] = useState<string | null>(null);
|
||||
const [sequence, setSequence] = useState<string>("");
|
||||
const [assignedTo, setAssignedTo] = useState<string | null>(null);
|
||||
const [taskType, setTaskType] = useState<TaskType>(TaskType.CODE);
|
||||
const [projectId, setProjectId] = useState<string>("");
|
||||
@@ -152,6 +154,14 @@ export function CreateTaskDialog() {
|
||||
"Pick either a Project or a Product, not both — a task targets one repo or fans out via a Product";
|
||||
}
|
||||
|
||||
const trimmedSequence = sequence.trim();
|
||||
if (
|
||||
trimmedSequence &&
|
||||
(!Number.isInteger(Number(trimmedSequence)) || Number(trimmedSequence) < 0)
|
||||
) {
|
||||
newErrors.sequence = "Sequence must be a non-negative whole number";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@@ -181,6 +191,7 @@ export function CreateTaskDialog() {
|
||||
...(dependencyIds.length > 0 && { dependency_ids: dependencyIds }),
|
||||
...(parentTaskId && { parent_task_id: parentTaskId }),
|
||||
...(assignedTo && { assigned_to: assignedTo }),
|
||||
...(sequence.trim() && { sequence: Number(sequence.trim()) }),
|
||||
});
|
||||
toast.success("Task created successfully");
|
||||
setOpen(false);
|
||||
@@ -201,6 +212,7 @@ export function CreateTaskDialog() {
|
||||
setAcceptanceCriteria([]);
|
||||
setDependencyIds([]);
|
||||
setParentTaskId(null);
|
||||
setSequence("");
|
||||
setAssignedTo(null);
|
||||
setTaskType(TaskType.CODE);
|
||||
setProjectId("");
|
||||
@@ -412,6 +424,26 @@ export function CreateTaskDialog() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sequence */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Order within siblings under the same parent — lower runs first, ties run in parallel. Leave blank to default to 0.">
|
||||
<Label htmlFor="sequence">Sequence</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="sequence"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={sequence}
|
||||
onChange={(e) => setSequence(e.target.value)}
|
||||
className={errors.sequence ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.sequence && (
|
||||
<p className="text-xs text-destructive">{errors.sequence}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign To */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Pin a specific agent; leave unassigned to let the orchestrator route by role, team, and availability.">
|
||||
|
||||
@@ -99,7 +99,7 @@ export function DependencySelector({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
className="shrink-0"
|
||||
onClick={() => removeTask(task.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
||||
@@ -123,6 +123,7 @@ function EditTaskDialogInner({
|
||||
const [budgetUsd, setBudgetUsd] = useState<string>(
|
||||
task.budget_usd != null ? String(task.budget_usd) : "",
|
||||
);
|
||||
const [sequence, setSequence] = useState<string>(String(task.sequence ?? 0));
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const updateTask = useUpdateTask();
|
||||
@@ -155,9 +156,14 @@ function EditTaskDialogInner({
|
||||
const trimmedBudget = budgetUsd.trim();
|
||||
const parsedBudget = trimmedBudget ? Number(trimmedBudget) : null;
|
||||
if (trimmedBudget && (Number.isNaN(parsedBudget) || parsedBudget! <= 0)) {
|
||||
toast.error(
|
||||
"Budget must be greater than 0 — leave it empty for no cap",
|
||||
);
|
||||
toast.error("Budget must be greater than 0 — leave it empty for no cap");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedSequence = sequence.trim();
|
||||
const parsedSequence = trimmedSequence === "" ? 0 : Number(trimmedSequence);
|
||||
if (!Number.isInteger(parsedSequence) || parsedSequence < 0) {
|
||||
toast.error("Sequence must be a non-negative whole number");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,6 +189,7 @@ function EditTaskDialogInner({
|
||||
assigned_to: assignedTo,
|
||||
target_date: targetDate ? new Date(targetDate).toISOString() : null,
|
||||
budget_usd: parsedBudget,
|
||||
sequence: parsedSequence,
|
||||
...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
|
||||
},
|
||||
});
|
||||
@@ -357,6 +364,21 @@ function EditTaskDialogInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sequence */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Order within siblings under the same parent — lower runs first. A sibling with a lower sequence must reach a terminal state before this one is claimable; ties run in parallel.">
|
||||
<Label htmlFor="edit-sequence">Sequence</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="edit-sequence"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={sequence}
|
||||
onChange={(e) => setSequence(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Budget (USD) */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Caps this task's own agent-spawn spend; only enforced when the task-budgets feature flag is on. Empty = no cap.">
|
||||
@@ -376,7 +398,10 @@ function EditTaskDialogInner({
|
||||
enforce only when explicitly set.
|
||||
</p>
|
||||
{spendUsd != null && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="task-spend">
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="task-spend"
|
||||
>
|
||||
Spent: ${spendUsd.toFixed(2)}
|
||||
{budgetUsd.trim() && !Number.isNaN(Number(budgetUsd))
|
||||
? ` / $${Number(budgetUsd).toFixed(2)}`
|
||||
|
||||
@@ -114,7 +114,7 @@ export function TabCommits({ task }: TabCommitsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="grid grid-cols-1 items-center gap-2 sm:grid-cols-3 sm:gap-4">
|
||||
<HelpTip label="Commits attributed to this task — new ones link automatically as devs push">
|
||||
<CardTitle className="text-lg flex items-center gap-2 w-fit">
|
||||
<GitCommit className="h-5 w-5" />
|
||||
@@ -124,7 +124,7 @@ export function TabCommits({ task }: TabCommitsProps) {
|
||||
</span>
|
||||
</CardTitle>
|
||||
</HelpTip>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-start gap-2 overflow-x-auto sm:justify-center">
|
||||
{task.branch_name && (
|
||||
<HelpTip label="Git branch this task is being worked on">
|
||||
<Badge
|
||||
@@ -277,7 +277,9 @@ export function TabCommits({ task }: TabCommitsProps) {
|
||||
)}
|
||||
|
||||
{/* Time */}
|
||||
<HelpTip label={formatAbsoluteTimestamp(commit.timestamp)}>
|
||||
<HelpTip
|
||||
label={formatAbsoluteTimestamp(commit.timestamp)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(commit.timestamp)}
|
||||
@@ -291,9 +293,9 @@ export function TabCommits({ task }: TabCommitsProps) {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(commit.hash)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
className="h-9 w-9 p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,10 @@ function AlertDialogContent({
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
// max-h + overflow-y-auto (mirroring DialogContent) so a tall
|
||||
// description doesn't get clipped off-screen at a short viewport
|
||||
// height — the body scrolls while the dialog itself stays centered.
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[85vh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -84,7 +87,9 @@ function AlertDialogFooter({
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
// sticky + bg-background (mirroring DialogFooter) so the action
|
||||
// buttons stay pinned and visible while a long body scrolls above.
|
||||
"bg-background sticky bottom-0 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -22,7 +22,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { tasksApi } from "@/lib/api/tasks";
|
||||
import { taskKeys } from "@/hooks/use-tasks";
|
||||
import { useAgentDefinitions } from "@/hooks/use-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useUIStore } from "@/store";
|
||||
import { navItems } from "@/components/layout/sidebar";
|
||||
import { fuzzyScore } from "@/lib/fuzzy-match";
|
||||
import {
|
||||
addRecent,
|
||||
loadRecents,
|
||||
type CommandRecentType,
|
||||
} from "@/lib/command-palette-recents";
|
||||
|
||||
export interface CommandItem {
|
||||
type: CommandRecentType;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const MAX_RESULTS_PER_GROUP = 6;
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function scoreAndSort<T>(
|
||||
query: string,
|
||||
items: T[],
|
||||
getLabel: (item: T) => string,
|
||||
): T[] {
|
||||
return items
|
||||
.map((item) => ({ item, score: fuzzyScore(query, getLabel(item)) }))
|
||||
.filter((entry): entry is { item: T; score: number } => entry.score !== null)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.slice(0, MAX_RESULTS_PER_GROUP)
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
/** A group of command items with a section label, in fixed display order. */
|
||||
export interface CommandGroup {
|
||||
label: string;
|
||||
items: CommandItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Data + keyboard-navigation state for the command palette: fuzzy search
|
||||
* across tasks/agents/projects/pages (or localStorage recents when the
|
||||
* query is empty), a flat selectable index across every visible item, and
|
||||
* navigation that records the pick as a recent before routing to it.
|
||||
*/
|
||||
export function useCommandPalette() {
|
||||
const open = useUIStore((s) => s.commandPaletteOpen);
|
||||
const setOpen = useUIStore((s) => s.setCommandPaletteOpen);
|
||||
const router = useRouter();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const debouncedQuery = useDebouncedValue(query, DEBOUNCE_MS);
|
||||
const trimmedQuery = debouncedQuery.trim();
|
||||
|
||||
// Typing a new query jumps the selection back to the top match; this runs
|
||||
// from the input's onChange event, not an effect, so there's no
|
||||
// render-triggers-setState-triggers-render cascade.
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
setSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
// Dialog open/close is driven by Radix's onOpenChange event. Closing also
|
||||
// resets the transient query/selection so the next open starts fresh.
|
||||
const setDialogOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setQuery("");
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
// Tasks: the backend already fuzzy-searches title/description/id-prefix
|
||||
// (see TaskFilters.q), so this is a plain server-side query, only run
|
||||
// while the dialog is open and the query is non-empty.
|
||||
const { data: taskResults = [] } = useQuery({
|
||||
queryKey: taskKeys.list({ q: trimmedQuery, limit: MAX_RESULTS_PER_GROUP }),
|
||||
queryFn: () =>
|
||||
tasksApi.list({ q: trimmedQuery, limit: MAX_RESULTS_PER_GROUP }),
|
||||
enabled: open && trimmedQuery.length > 0,
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { data: agentDefinitions = [] } = useAgentDefinitions();
|
||||
const { data: projects = [] } = useProjects();
|
||||
|
||||
const groups = useMemo<CommandGroup[]>(() => {
|
||||
if (!trimmedQuery) {
|
||||
// Read directly (no cached state) each time this recomputes — cheap,
|
||||
// and it means a pick made just before closing shows up immediately
|
||||
// the next time the dialog opens.
|
||||
const recents = open ? loadRecents() : [];
|
||||
return [
|
||||
{
|
||||
label: "Recent",
|
||||
items: recents.map((r) => ({ ...r, href: hrefFor(r.type, r.id) })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const taskItems: CommandItem[] = taskResults.map((t) => ({
|
||||
type: "task",
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
subtitle: `#${t.id.slice(0, 8)}`,
|
||||
href: hrefFor("task", t.id),
|
||||
}));
|
||||
|
||||
const agentItems: CommandItem[] = scoreAndSort(
|
||||
trimmedQuery,
|
||||
agentDefinitions,
|
||||
(a) => `${a.name} ${a.id}`,
|
||||
).map((a) => ({
|
||||
type: "agent",
|
||||
id: a.id,
|
||||
title: a.name,
|
||||
subtitle: `@${a.id}`,
|
||||
href: hrefFor("agent", a.id),
|
||||
}));
|
||||
|
||||
// No dedicated project detail route exists, so the project's `id` here
|
||||
// is its name — the value hrefFor uses to build the /projects?q= filter
|
||||
// link — kept consistent between a fresh search hit and a replayed
|
||||
// recent (which only carries {type, id, title}, not the full project).
|
||||
const projectItems: CommandItem[] = scoreAndSort(
|
||||
trimmedQuery,
|
||||
projects,
|
||||
(p) => p.name,
|
||||
).map((p) => ({
|
||||
type: "project",
|
||||
id: p.name,
|
||||
title: p.name,
|
||||
subtitle: p.slug,
|
||||
href: hrefFor("project", p.name),
|
||||
}));
|
||||
|
||||
const pageItems: CommandItem[] = scoreAndSort(
|
||||
trimmedQuery,
|
||||
navItems,
|
||||
(p) => p.title,
|
||||
).map((p) => ({
|
||||
type: "page",
|
||||
id: p.href,
|
||||
title: p.title,
|
||||
href: p.href,
|
||||
}));
|
||||
|
||||
return [
|
||||
{ label: "Tasks", items: taskItems },
|
||||
{ label: "Agents", items: agentItems },
|
||||
{ label: "Projects", items: projectItems },
|
||||
{ label: "Pages", items: pageItems },
|
||||
];
|
||||
}, [trimmedQuery, taskResults, agentDefinitions, projects, open]);
|
||||
|
||||
const flatItems = useMemo(
|
||||
() => groups.flatMap((g) => g.items),
|
||||
[groups],
|
||||
);
|
||||
|
||||
const moveSelection = useCallback(
|
||||
(delta: number) => {
|
||||
if (flatItems.length === 0) return;
|
||||
setSelectedIndex((prev) => {
|
||||
const next = (prev + delta + flatItems.length) % flatItems.length;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[flatItems.length],
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(item: CommandItem) => {
|
||||
addRecent({ type: item.type, id: item.id, title: item.title });
|
||||
setDialogOpen(false);
|
||||
router.push(item.href);
|
||||
},
|
||||
[router, setDialogOpen],
|
||||
);
|
||||
|
||||
const selectCurrent = useCallback(() => {
|
||||
const item = flatItems[selectedIndex];
|
||||
if (item) navigateTo(item);
|
||||
}, [flatItems, selectedIndex, navigateTo]);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen: setDialogOpen,
|
||||
query,
|
||||
setQuery: handleQueryChange,
|
||||
groups,
|
||||
flatItems,
|
||||
selectedIndex,
|
||||
moveSelection,
|
||||
selectCurrent,
|
||||
navigateTo,
|
||||
};
|
||||
}
|
||||
|
||||
function hrefFor(type: CommandRecentType, id: string): string {
|
||||
switch (type) {
|
||||
case "task":
|
||||
return `/tasks/${id}`;
|
||||
case "agent":
|
||||
return `/agents/${id}`;
|
||||
case "project":
|
||||
// No dedicated project detail route exists — filter the list to it.
|
||||
return `/projects?q=${encodeURIComponent(id)}`;
|
||||
case "page":
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadRecents, addRecent } from "@/lib/command-palette-recents";
|
||||
|
||||
const STORAGE_KEY = "roboco-cmd-recents";
|
||||
|
||||
describe("command-palette-recents", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing is stored", () => {
|
||||
expect(loadRecents()).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds an entry to the front and persists it under the expected key", () => {
|
||||
const result = addRecent({ type: "task", id: "abc123", title: "Fix bug" });
|
||||
expect(result).toEqual([{ type: "task", id: "abc123", title: "Fix bug" }]);
|
||||
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY)!)).toEqual([
|
||||
{ type: "task", id: "abc123", title: "Fix bug" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a re-added entry to the front instead of duplicating it", () => {
|
||||
addRecent({ type: "task", id: "a", title: "Task A" });
|
||||
addRecent({ type: "task", id: "b", title: "Task B" });
|
||||
const result = addRecent({ type: "task", id: "a", title: "Task A (renamed)" });
|
||||
expect(result).toEqual([
|
||||
{ type: "task", id: "a", title: "Task A (renamed)" },
|
||||
{ type: "task", id: "b", title: "Task B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps the list at 10 entries, dropping the oldest", () => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
addRecent({ type: "agent", id: `agent-${i}`, title: `Agent ${i}` });
|
||||
}
|
||||
const result = loadRecents();
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result[0]).toEqual({ type: "agent", id: "agent-11", title: "Agent 11" });
|
||||
expect(result.find((r) => r.id === "agent-0")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores malformed JSON and falls back to an empty list", () => {
|
||||
window.localStorage.setItem(STORAGE_KEY, "{not json");
|
||||
expect(loadRecents()).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries missing required shape", () => {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([{ type: "task", id: "ok", title: "Ok" }, { type: "bogus" }, null]),
|
||||
);
|
||||
expect(loadRecents()).toEqual([{ type: "task", id: "ok", title: "Ok" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fuzzyScore, fuzzyMatch } from "@/lib/fuzzy-match";
|
||||
|
||||
describe("fuzzyScore", () => {
|
||||
it("matches an empty query against anything with the best score", () => {
|
||||
expect(fuzzyScore("", "anything")).toBe(0);
|
||||
});
|
||||
|
||||
it("scores an exact substring match by its start index", () => {
|
||||
expect(fuzzyScore("dev", "fe-dev-2")).toBe("fe-dev-2".indexOf("dev"));
|
||||
expect(fuzzyScore("fe-dev", "fe-dev-2")).toBe(0);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(fuzzyScore("DEV", "fe-dev-2")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches a non-contiguous subsequence and penalizes larger gaps", () => {
|
||||
const tight = fuzzyScore("cmp", "command palette");
|
||||
const loose = fuzzyScore("cmp", "c a a m a a p");
|
||||
expect(tight).not.toBeNull();
|
||||
expect(loose).not.toBeNull();
|
||||
expect(tight as number).toBeLessThan(loose as number);
|
||||
});
|
||||
|
||||
it("returns null when the query is not a subsequence of the target", () => {
|
||||
expect(fuzzyScore("xyz", "command palette")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("is true for a subsequence match and false otherwise", () => {
|
||||
expect(fuzzyMatch("cp", "command palette")).toBe(true);
|
||||
expect(fuzzyMatch("zzz", "command palette")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// localStorage-backed "recently opened" list for the command palette.
|
||||
// Read/written only from the browser; every call is a no-op on the server.
|
||||
|
||||
const RECENTS_KEY = "roboco-cmd-recents";
|
||||
const RECENTS_CAP = 10;
|
||||
|
||||
export type CommandRecentType = "task" | "agent" | "project" | "page";
|
||||
|
||||
export interface CommandRecent {
|
||||
type: CommandRecentType;
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function isCommandRecent(value: unknown): value is CommandRecent {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const r = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof r.id === "string" &&
|
||||
typeof r.title === "string" &&
|
||||
(r.type === "task" ||
|
||||
r.type === "agent" ||
|
||||
r.type === "project" ||
|
||||
r.type === "page")
|
||||
);
|
||||
}
|
||||
|
||||
/** Most-recent-first list of past command-palette selections, capped at 10. */
|
||||
export function loadRecents(): CommandRecent[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENTS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isCommandRecent).slice(0, RECENTS_CAP);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves `entry` to the front of the recents list (de-duped by type+id). */
|
||||
export function addRecent(entry: CommandRecent): CommandRecent[] {
|
||||
const deduped = loadRecents().filter(
|
||||
(r) => !(r.type === entry.type && r.id === entry.id),
|
||||
);
|
||||
const next = [entry, ...deduped].slice(0, RECENTS_CAP);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Case-insensitive fuzzy match: a query matches a target when every query
|
||||
* character appears in the target in order (not necessarily contiguous).
|
||||
* Lower scores rank first. An exact substring hit ranks by how early it
|
||||
* starts; a non-contiguous subsequence hit is penalized by the total gap
|
||||
* between matched characters so tighter matches still outrank loose ones.
|
||||
* Returns null when the query isn't a subsequence of the target at all.
|
||||
*/
|
||||
export function fuzzyScore(query: string, target: string): number | null {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return 0;
|
||||
const t = target.toLowerCase();
|
||||
|
||||
const substringIndex = t.indexOf(q);
|
||||
if (substringIndex !== -1) return substringIndex;
|
||||
|
||||
let searchFrom = 0;
|
||||
let gap = 0;
|
||||
let lastMatch = -1;
|
||||
for (const ch of q) {
|
||||
const found = t.indexOf(ch, searchFrom);
|
||||
if (found === -1) return null;
|
||||
if (lastMatch !== -1) gap += found - lastMatch - 1;
|
||||
lastMatch = found;
|
||||
searchFrom = found + 1;
|
||||
}
|
||||
return t.length + gap;
|
||||
}
|
||||
|
||||
/** True when `query` fuzzy-matches `target` (see `fuzzyScore`). */
|
||||
export function fuzzyMatch(query: string, target: string): boolean {
|
||||
return fuzzyScore(query, target) !== null;
|
||||
}
|
||||
+34
-36
@@ -3,6 +3,8 @@ import { persist } from "zustand/middleware";
|
||||
import type { Team } from "@/types";
|
||||
import { DEFAULT_QUICK_ACTION_IDS } from "@/components/dashboard/quick-actions-registry";
|
||||
|
||||
export type CardTableView = "cards" | "table";
|
||||
|
||||
interface UIState {
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
@@ -14,45 +16,42 @@ interface UIState {
|
||||
// Current context
|
||||
currentTeam: Team | null;
|
||||
|
||||
// A2A live view: xl:+ context pane collapse (conversation-first layout
|
||||
// design doc §1) — same persisted-preference idiom as sidebar/theme.
|
||||
a2aContextOpen: boolean;
|
||||
// Command palette (Cmd+K) — not persisted, always closed on reload
|
||||
commandPaletteOpen: boolean;
|
||||
|
||||
// Workstation cards/table toggle, persisted per-surface so Products and
|
||||
// Projects remember their own choice independently. Cards is the default.
|
||||
productsView: "cards" | "table";
|
||||
projectsView: "cards" | "table";
|
||||
|
||||
// Client-only Settings-page prefs (never sent to the backend — the
|
||||
// server's settings allowlist is transcript_retention_days + feature
|
||||
// flags only). Same persisted-preference idiom as sidebar/theme.
|
||||
// Settings: Notifications & data refresh — client-only prefs, never sent
|
||||
// to the backend.
|
||||
notificationsEnabled: boolean;
|
||||
soundEnabled: boolean;
|
||||
autoRefresh: boolean;
|
||||
refreshIntervalSeconds: number;
|
||||
|
||||
// Quick Actions (Overview dashboard) — ordered list of
|
||||
// quick-actions-registry ids the CEO has chosen to show. Per-browser only,
|
||||
// same persisted-preference idiom as everything else in this store; see
|
||||
// quick-actions-card.tsx for the render/customize side.
|
||||
// A2A: xl:+ context pane collapse
|
||||
a2aContextOpen: boolean;
|
||||
|
||||
// Overview dashboard quick actions — ids + display order, customizable
|
||||
quickActionIds: string[];
|
||||
|
||||
// Workstation tab view modes
|
||||
productsView: CardTableView;
|
||||
projectsView: CardTableView;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setTheme: (theme: "light" | "dark" | "system") => void;
|
||||
setCurrentTeam: (team: Team | null) => void;
|
||||
toggleA2AContext: () => void;
|
||||
setProductsView: (view: "cards" | "table") => void;
|
||||
setProjectsView: (view: "cards" | "table") => void;
|
||||
setCommandPaletteOpen: (open: boolean) => void;
|
||||
toggleCommandPaletteOpen: () => void;
|
||||
setNotificationsEnabled: (enabled: boolean) => void;
|
||||
setSoundEnabled: (enabled: boolean) => void;
|
||||
setAutoRefresh: (enabled: boolean) => void;
|
||||
setRefreshIntervalSeconds: (seconds: number) => void;
|
||||
|
||||
// Quick Actions actions
|
||||
toggleA2AContext: () => void;
|
||||
setQuickActionIds: (ids: string[]) => void;
|
||||
resetQuickActionIds: () => void;
|
||||
setProductsView: (view: CardTableView) => void;
|
||||
setProjectsView: (view: CardTableView) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
@@ -62,37 +61,37 @@ export const useUIStore = create<UIState>()(
|
||||
sidebarCollapsed: false,
|
||||
theme: "system",
|
||||
currentTeam: null,
|
||||
a2aContextOpen: true,
|
||||
productsView: "cards",
|
||||
projectsView: "cards",
|
||||
commandPaletteOpen: false,
|
||||
notificationsEnabled: true,
|
||||
soundEnabled: true,
|
||||
autoRefresh: false, // default-off: never start a background poller unasked
|
||||
autoRefresh: false,
|
||||
refreshIntervalSeconds: 30,
|
||||
|
||||
// Quick Actions default
|
||||
a2aContextOpen: true,
|
||||
quickActionIds: DEFAULT_QUICK_ACTION_IDS,
|
||||
productsView: "cards",
|
||||
projectsView: "cards",
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setCurrentTeam: (team) => set({ currentTeam: team }),
|
||||
toggleA2AContext: () =>
|
||||
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
|
||||
setProductsView: (view) => set({ productsView: view }),
|
||||
setProjectsView: (view) => set({ projectsView: view }),
|
||||
setCommandPaletteOpen: (open) => set({ commandPaletteOpen: open }),
|
||||
toggleCommandPaletteOpen: () =>
|
||||
set((state) => ({ commandPaletteOpen: !state.commandPaletteOpen })),
|
||||
setNotificationsEnabled: (enabled) =>
|
||||
set({ notificationsEnabled: enabled }),
|
||||
setSoundEnabled: (enabled) => set({ soundEnabled: enabled }),
|
||||
setAutoRefresh: (enabled) => set({ autoRefresh: enabled }),
|
||||
setRefreshIntervalSeconds: (seconds) =>
|
||||
set({ refreshIntervalSeconds: seconds }),
|
||||
|
||||
// Quick Actions actions
|
||||
toggleA2AContext: () =>
|
||||
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
|
||||
setQuickActionIds: (ids) => set({ quickActionIds: ids }),
|
||||
resetQuickActionIds: () =>
|
||||
set({ quickActionIds: DEFAULT_QUICK_ACTION_IDS }),
|
||||
setProductsView: (view) => set({ productsView: view }),
|
||||
setProjectsView: (view) => set({ projectsView: view }),
|
||||
}),
|
||||
{
|
||||
name: "roboco-ui-storage",
|
||||
@@ -100,15 +99,14 @@ export const useUIStore = create<UIState>()(
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
theme: state.theme,
|
||||
currentTeam: state.currentTeam,
|
||||
a2aContextOpen: state.a2aContextOpen,
|
||||
productsView: state.productsView,
|
||||
projectsView: state.projectsView,
|
||||
notificationsEnabled: state.notificationsEnabled,
|
||||
soundEnabled: state.soundEnabled,
|
||||
autoRefresh: state.autoRefresh,
|
||||
refreshIntervalSeconds: state.refreshIntervalSeconds,
|
||||
// Quick Actions
|
||||
a2aContextOpen: state.a2aContextOpen,
|
||||
quickActionIds: state.quickActionIds,
|
||||
productsView: state.productsView,
|
||||
projectsView: state.projectsView,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user