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
@@ -20,6 +20,8 @@ Documentation for the Frontend Cell team.
|
||||
- [`a2a-filtering.md`](./a2a-filtering.md) — A2A filter bar, conversation list, and pulse-flash hook
|
||||
- [`a2a-conversation-first-layout.md`](./a2a-conversation-first-layout.md) — Agent identity colors, connection states, context pane, transcript motion, and empty/error states
|
||||
- [`hooks.md`](./hooks.md) — `usePageRefresh` and `PageRefreshProvider` usage and API reference
|
||||
- [`forms/forms-audit.md`](./forms/forms-audit.md) — the living panel-forms ↔ backend-schema consistency audit (settings page + project + task dialogs); update the matching row in the same PR as any schema change
|
||||
- [`forms/project-fields-audit.md`](./forms/project-fields-audit.md) — project dialog field reference (types, create-vs-edit exposure, the add-a-field checklist)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Panel Forms ↔ Backend Data-Consistency Audit
|
||||
|
||||
**This is a living reference artifact.** Any PR that changes a backend request schema touched by a form listed below (`ProjectCreateRequest`/`ProjectUpdateRequest` in `roboco/api/schemas/project.py`, `TaskCreate` in `roboco/models/task.py`, the route-level `TaskUpdate` in `roboco/api/schemas/tasks.py`, or a writable settings key in `roboco/services/settings.py`) must update the matching row in this table in the same PR. A stale row here is worse than no row — it tells the next developer a mismatch was checked when it wasn't.
|
||||
|
||||
Audited 2026-07-23 directly against the current codebase (v0.26.0) on branch `feature/frontend/73275ff0--170c9578--515697f4` — every verdict below was checked by reading the live dialog component next to the live backend schema, not assumed from the intake's original description. Several fields the intake named as "missing" turned out to already be shipped (by unrelated feature work — the env-branches ladder, forge providers, and task/project cost budgets initiatives all landed after this audit's premise was written); this table reflects what is actually true today.
|
||||
|
||||
## Verdict legend
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| `ok` | Frontend field/control matches the backend schema and its constraints. |
|
||||
| `missing` | The backend schema accepts this field but no form control exists for it. |
|
||||
| `stale` | A label, placeholder, help text, or default no longer matches actual behavior. |
|
||||
| `validation-mismatch` | The form validates (or fails to validate) differently than the backend will. |
|
||||
| `n/a` | Deliberately absent by design (system-managed field, role-gated note field, or lifecycle-governed elsewhere). |
|
||||
|
||||
---
|
||||
|
||||
## Settings page (Stream1-C — this task)
|
||||
|
||||
Form: `panel/src/app/(dashboard)/settings/page.tsx`. Backend: `roboco/services/settings.py` (`SettingsService`, `_VALIDATORS`) plus `panel/src/store/ui-store.ts` (`useUIStore`, client-only, zustand `persist`).
|
||||
|
||||
**Persistence mechanism for the four toggle rows below (Enable Notifications, Sound Alerts, Auto Refresh, Refresh Interval):** these do NOT round-trip through `settingsApi`/`SettingsService` — they persist entirely client-side via `useUIStore` (zustand `persist` middleware, backed by `localStorage`; see `settings/page.tsx` lines 44-60), with a `toast.success(...)` confirmation fired on every change. `settingsApi` is used by exactly one row on this page — Retention window (days) — see its own row below.
|
||||
|
||||
| Field | Verdict | Notes |
|
||||
|---|---|---|
|
||||
| Enable Notifications | `ok` (fixed) | Was already migrated to client-only `useUIStore` persistence (localStorage, immediate-apply, no Save button) by a prior fix — see "History" below. This task adds the one piece that was still missing: a confirmation toast on every change. |
|
||||
| Sound Alerts | `ok` (fixed) | Same as above; also client-disabled while Notifications is off. |
|
||||
| Auto Refresh | `ok` (fixed) | Same as above. |
|
||||
| Refresh Interval | `ok` (fixed) | Same as above; toast now names the new interval. |
|
||||
| Theme | `n/a` | Client-only (`next-themes`), never sent to a server. |
|
||||
| Collapsed Sidebar | `n/a` | Client-only (`useUIStore`), never sent to a server. |
|
||||
| Retention window (days) | `ok` | `TranscriptRetentionCard` uses the server-persisted `settingsApi` pattern — this is the one setting that genuinely round-trips to the backend. |
|
||||
| Feature flag switches | `ok` | `FeatureFlagsCard` writes immediately per-flag with toast feedback. |
|
||||
|
||||
### History (why these four fields are client-only, not server-persisted)
|
||||
|
||||
An earlier fix discovered that `roboco/services/settings.py`'s `_VALIDATORS` allowlist never contained `notifications_enabled`/`sound_enabled`/`auto_refresh`/`refresh_interval` — every save 422'd, and nothing consumed the values anywhere (no auto-refresh timer, no notification toast, no sound). Rather than widening the backend allowlist, that fix moved the four prefs into `useUIStore` (client-only, same idiom as theme/sidebar) and **deliberately left the backend allowlist untouched** ("the backend allowlist stays strict and untouched" — see the CHANGELOG entry "Settings preferences persist as real client prefs instead of 422-ing as theater"). It also built `AutoRefreshDriver` (ticks the page-refresh registry when Auto Refresh is on) and `NotificationAlerts` (toasts + optional chime on new WS notifications), so the prefs are now both persisted and actually consumed. This task's own acceptance criterion ("read/write through settingsApi") predates that fix and is superseded by it; adding the four keys back to `_VALIDATORS` would be a regression against a documented, deliberate decision, so this task does not touch `roboco/services/settings.py`.
|
||||
|
||||
---
|
||||
|
||||
## Project dialogs (Stream1-A)
|
||||
|
||||
Forms: `panel/src/components/projects/create-project-dialog.tsx` (backend: `ProjectCreateRequest`) and `edit-project-dialog.tsx` (backend: `ProjectUpdateRequest`), both in `roboco/api/schemas/project.py`.
|
||||
|
||||
Every field the intake's Stream1-A unit named as missing — `codegen_command`, `git_provider`, `github_installation_id`, `environments`, `monthly_budget_usd`, `sandbox_extensions` — is now present in both the backend schema and the corresponding dialog, shipped by unrelated feature work (the env-branches ladder, forge-provider, and sandbox-extensions initiatives) that landed after Stream1-A was scoped. Stream1-A's field-sync objective is already fully met.
|
||||
|
||||
| Field | Create dialog | Edit dialog | Verdict | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `name` | ok | ok | `ok` | |
|
||||
| `slug` | ok (create-only, immutable) | read-only display | `ok` (fixed help text) | Help text said "lowercase, hyphens only" but the backend pattern (`^[a-z0-9-]+$`) also allows digits — fixed to "lowercase letters, numbers, hyphens". |
|
||||
| `git_url` | ok | ok | `ok` | |
|
||||
| `git_provider` | ok (Forge select) | ok (Forge select) | `ok` | |
|
||||
| `github_installation_id` | ok (Select-repo picker) | ok (Select-repo picker + Unbind) | `ok` | |
|
||||
| `default_branch` | ok | ok | `ok` | |
|
||||
| `protected_branches` | `n/a` (create has no such field on the backend either) | ok (chip editor) | `ok` | `ProjectCreateRequest.protected_branches` exists but defaults to `[default_branch]`; the create dialog correctly leaves it for the edit dialog post-creation. |
|
||||
| `environments` | ok (`EnvironmentLadderEditor`) | ok (`EnvironmentLadderEditor`) | `ok` | |
|
||||
| `git_token` | ok | ok (set/replace/clear flow) | `ok` | |
|
||||
| `test_command` / `lint_command` / `format_command` / `typecheck_command` / `build_command` / `quality_command` / `codegen_command` | ok (create lacks `codegen_command` — see note) | ok | `ok` | `codegen_command` is edit-only in the UI even though `ProjectCreateRequest` accepts it — matches the create dialog's own comment that autonomous/advanced config is deliberately deferred to "Edit Project" post-creation. |
|
||||
| `is_active` | `n/a` (create has no such field) | ok | `ok` | |
|
||||
| `ci_watch_enabled` / `ci_watch_workflow` | `n/a` (create has no such field) | ok | `ok` | |
|
||||
| `video_engine_enabled` | `n/a` (create has no such field) | ok | `ok` | |
|
||||
| `dep_update_command` / `dep_update_paths` | `n/a` (create has no such field) | ok | `ok` | |
|
||||
| `monthly_budget_usd` | `n/a` (`ProjectCreateRequest` has no such field — update-only) | ok | `ok` | |
|
||||
| `sandbox_services` / `sandbox_extensions` | `n/a` (create has no such field) | ok | `ok` | |
|
||||
|
||||
---
|
||||
|
||||
## Task dialogs (Stream1-B)
|
||||
|
||||
Forms: `panel/src/components/tasks/create-task-dialog.tsx` (backend: `TaskCreate` in `roboco/models/task.py`) and `edit-task-dialog.tsx` (backend: the route-level `TaskUpdate` in `roboco/api/schemas/tasks.py` — a richer schema than the internal `roboco/models/task.py TaskUpdate`; the API route imports the former).
|
||||
|
||||
`acceptance_criteria` editing on the edit dialog — the one gap the intake explicitly named for this unit — is already shipped. The remaining gaps below were found during this audit sweep, not named by the intake.
|
||||
|
||||
| Field | Create dialog | Edit dialog | Verdict | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `title` | ok | ok | `ok` | |
|
||||
| `description` | ok (Markdown editor, 20-char min) | ok | `ok` | |
|
||||
| `acceptance_criteria` | ok (`AcceptanceCriteriaEditor`) | ok (`AcceptanceCriteriaEditor`) | `ok` | |
|
||||
| `team` | ok | ok | `ok` | |
|
||||
| `priority` | ok | ok | `ok` | |
|
||||
| `sequence` | ok | ok | `ok` | `TaskCreate.sequence` (create) and the route-level `TaskUpdate.sequence` (edit) both accept sibling ordering; both dialogs now expose a numeric control for it (behind "Advanced Options"), following the existing `budget_usd` numeric-input pattern. |
|
||||
| `status` | ok (Pending/Backlog at creation) | `n/a` | `n/a` | Post-creation status changes are lifecycle transitions (claim/complete/etc.) gated through role-specific verbs, not a free-text edit field. |
|
||||
| `estimated_complexity` | ok | ok | `ok` | |
|
||||
| `nature` | ok | ok | `ok` | |
|
||||
| `task_type` | ok (behind "Advanced Options") | ok (behind "Advanced Options") | `ok` | |
|
||||
| `target_date` | missing | ok | `missing` (deferred) | `TaskCreate.target_date` accepts it; only the edit dialog exposes a control. Discovered during this audit sweep, not named as a deliverable by the intake — deferred/out-of-scope for this wave rather than building a new create-dialog control; tracked as a follow-up, not fixed here. |
|
||||
| `budget_usd` | `n/a` (`TaskCreate` has no such field — update-only) | ok | `ok` (fixed help text) | Mirrors the project dialog's `monthly_budget_usd` shape (create has no field, edit does, both intentional per the backend schema). Help text and validation toast said "empty = task-type default" — that default table was removed (`effective_task_budget_usd`: null = no cap); both strings now say "no cap". |
|
||||
| `project_id` / `product_id` | ok (behind "Advanced Options") | ok (`project_id` only; locked once `branch_name` is set) | `ok` | `product_id` is create-only by design — a fan-out task's per-cell routing is fixed at creation and `TaskUpdate` carries no `product_id`. |
|
||||
| `parent_task_id` | ok (behind "Advanced Options") | missing | `missing` (deferred) | The route-level `TaskUpdate.parent_task_id` accepts re-parenting after creation; the edit dialog has no control for it. Discovered during this audit sweep, not named as a deliverable by the intake — deferred/out-of-scope for this wave; tracked as a follow-up, not fixed here. |
|
||||
| `dependency_ids` | ok (`DependencySelector`) | missing | `missing` (deferred) | The route-level `TaskUpdate.dependency_ids` accepts it; the edit dialog has no equivalent control even though `Task.dependency_ids` is already in the type. Discovered during this audit sweep, not named as a deliverable by the intake — deferred/out-of-scope for this wave; tracked as a follow-up, not fixed here. |
|
||||
| `blocker_ids` | `n/a` | `n/a` | `n/a` | Present on the route-level `TaskUpdate` schema but every non-test producer of blockers found in `roboco/services/` sets it programmatically (dependency/lifecycle bookkeeping); no evidence this is meant as a manually-edited dialog field, so it is left as a system-managed field pending explicit product direction otherwise. |
|
||||
| `assigned_to` | ok (behind "Advanced Options") | ok (behind "Advanced Options") | `ok` | |
|
||||
| `dev_notes` / `qa_notes` / `auditor_notes` / `pr_reviewer_notes` / `doc_notes` / `quick_context` | `n/a` | `n/a` | `n/a` | Role-authored structured note fields written through each role's own gateway verbs, not general-purpose manual edit fields. |
|
||||
| `plan` / `progress_updates` | `n/a` | `n/a` | `n/a` | Agent execution-tracking artifacts, not manual dialog fields. |
|
||||
| `branch_name` / `pr_number` / `pr_url` / `docs_complete` / `pr_created` | `n/a` | `n/a` | `n/a` | System-managed git/work-session tracking fields, not user-editable. |
|
||||
@@ -0,0 +1,58 @@
|
||||
# Project Dialog Fields Reference
|
||||
|
||||
**Status:** Complete as of Task f1957610 (2026-07-23)
|
||||
|
||||
This document maps which project configuration fields are exposed in each dialog for future reference. The backend ProjectCreateRequest and ProjectUpdateRequest schemas are the source of truth; frontend types mirror them exactly. The per-field consistency VERDICTS (ok/missing/stale, across settings + project + task forms) live in the sibling [`forms-audit.md`](./forms-audit.md) — this file is the field/type reference, that one is the living audit; update both when a schema changes.
|
||||
|
||||
## Create Project Dialog
|
||||
|
||||
Fields exposed in `panel/src/components/projects/create-project-dialog.tsx`:
|
||||
|
||||
| Field | Type | Optional | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `name` | string | No | Display name, can be changed post-creation |
|
||||
| `slug` | string | No | Immutable identifier, derives workspace paths |
|
||||
| `git_url` | string | No | HTTPS URL for git clone and auth |
|
||||
| `git_provider` | enum | Yes | Auto-detect (github/gitea/gitlab), or explicit for self-hosted |
|
||||
| `github_installation_id` | number | Yes | Set via GitHub App picker; cleared on URL edits |
|
||||
| `git_token` | string | Yes | Encrypted at rest; optional at creation, updatable later |
|
||||
| `assigned_cell` | enum | No | Backend-enforced team ownership (BACKEND/FRONTEND/UX_UI) |
|
||||
| `default_branch` | string | Yes | Head branch for PRs when no environment ladder exists |
|
||||
| `environments` | ladder | Yes | Optional ordered environment rungs (head→prod) |
|
||||
| `test_command` | string | Yes | Reference only; not wired to automated gates |
|
||||
| `lint_command` | string | Yes | Runs in dev pre-submit (unless quality_command replaces both) |
|
||||
| `format_command` | string | Yes | Reference only; excluded from pre-submit for safety |
|
||||
| `typecheck_command` | string | Yes | Runs in dev pre-submit (unless quality_command replaces both) |
|
||||
| `build_command` | string | Yes | Reference only; test/build left to CI |
|
||||
| `quality_command` | string | Yes | When set, replaces lint + typecheck in pre-submit gate |
|
||||
| `codegen_command` | string | Yes | Regenerates checked-in artifacts before push |
|
||||
|
||||
**Note:** autonomous maintenance features (ci_watch, video_engine, dependency updates, sandbox services, budget) are configured **after** creation in the Edit Project dialog.
|
||||
|
||||
## Edit Project Dialog
|
||||
|
||||
All create fields above, **plus:**
|
||||
|
||||
| Field | Type | Optional | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `video_engine_enabled` | boolean | Yes | Opt-in per-project video rendering |
|
||||
| `monthly_budget_usd` | number | Yes | Client-side validation: gt=0, rejects 0 and negatives |
|
||||
| `sandbox_extensions` | object | Yes | Per-service module extensions (e.g., postgis for postgres) |
|
||||
| `protected_branches` | array | Yes | Union with hardcoded {master, main}; exact match, case-sensitive |
|
||||
|
||||
## Symmetry Notes
|
||||
|
||||
- **Asymmetric by design:** create-project-dialog focuses on git setup; edit-project-dialog adds maintenance/autonomy toggles.
|
||||
- **Type mirrors:** `ProjectCreate` and `ProjectUpdate` in `panel/src/types/index.ts` match the backend schemas exactly.
|
||||
- **Field validation:** monthly_budget_usd client-side gate mirrors backend Field(gt=0).
|
||||
- **No deferrals:** all fields mentioned in task f1957610 are present; no partial/deferred implementations exist.
|
||||
|
||||
## Future Changes
|
||||
|
||||
When adding a new project field to the backend ProjectCreateRequest or ProjectUpdateRequest:
|
||||
|
||||
1. Update the backend schema (`roboco/api/schemas/project.py`)
|
||||
2. Update both frontend types (`panel/src/types/index.ts`)
|
||||
3. Add to **both** dialogs (unless explicitly create-only or edit-only by design)
|
||||
4. Mirror help text and validation between dialogs where fields overlap
|
||||
5. Update this reference and the matching verdict row in [`forms-audit.md`](./forms-audit.md)
|
||||
@@ -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