From f580af38ec637ba30585be70238242fe69b58315 Mon Sep 17 00:00:00 2001 From: Suprie Date: Thu, 6 Aug 2026 11:50:25 +0700 Subject: [PATCH 1/3] bookkeeping and reordering --- docs/epics/optimization/prd-docs-accuracy.md | 1 + docs/epics/optimization/prd-frontend-test-coverage.md | 1 + docs/epics/optimization/prd-memory-hygiene.md | 1 + docs/epics/optimization/prd-process-discipline.md | 1 + docs/epics/optimization/prd-session-state-consolidation.md | 1 + docs/epics/overnight-orchestration/README.md | 2 +- docs/epics/selasar-revamp/prd-rebrand-tokens.md | 4 ++-- index.html | 1 + 8 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/epics/optimization/prd-docs-accuracy.md b/docs/epics/optimization/prd-docs-accuracy.md index f89d035..98f120d 100644 --- a/docs/epics/optimization/prd-docs-accuracy.md +++ b/docs/epics/optimization/prd-docs-accuracy.md @@ -7,6 +7,7 @@ description: > Audit docs/PRD.md and CLAUDE.md against the code that actually ships, then rewrite both so neither states a non-goal the code violates or a file structure that no longer exists. No code changes — documentation only. +order: 10 --- # PRD — Docs Accuracy diff --git a/docs/epics/optimization/prd-frontend-test-coverage.md b/docs/epics/optimization/prd-frontend-test-coverage.md index 188f3f0..50820ae 100644 --- a/docs/epics/optimization/prd-frontend-test-coverage.md +++ b/docs/epics/optimization/prd-frontend-test-coverage.md @@ -8,6 +8,7 @@ description: > Vite + React 19 stack) and close the three coverage gaps a review flagged as highest-risk: streaming channels, migration cards, and permission- approval flows. Not a general coverage mandate — closes named gaps only. +order: 30 --- # PRD — Frontend Test Coverage diff --git a/docs/epics/optimization/prd-memory-hygiene.md b/docs/epics/optimization/prd-memory-hygiene.md index 18247cd..5a87bce 100644 --- a/docs/epics/optimization/prd-memory-hygiene.md +++ b/docs/epics/optimization/prd-memory-hygiene.md @@ -8,6 +8,7 @@ description: > and decisions.md, lower the archive trigger below the 90KB threshold that already proved insufficient once, and compact current content to fit — without deleting anything, only archiving it. +order: 40 --- # PRD — Memory Hygiene diff --git a/docs/epics/optimization/prd-process-discipline.md b/docs/epics/optimization/prd-process-discipline.md index 6c9a7d5..9e07e63 100644 --- a/docs/epics/optimization/prd-process-discipline.md +++ b/docs/epics/optimization/prd-process-discipline.md @@ -9,6 +9,7 @@ description: > amendments about amendments — after a review found this pattern in recent loop history plus a near-miss where an agent almost deleted the orchestrator by misreading its own memory. +order: 50 --- # PRD — Process Discipline diff --git a/docs/epics/optimization/prd-session-state-consolidation.md b/docs/epics/optimization/prd-session-state-consolidation.md index de47db8..9ce84ee 100644 --- a/docs/epics/optimization/prd-session-state-consolidation.md +++ b/docs/epics/optimization/prd-session-state-consolidation.md @@ -9,6 +9,7 @@ description: > commands/agent.rs (1731 lines) if it's still oversized once the maps are unified. The most concurrency-sensitive surface in the app, so this opens with a spike phase before any code moves. +order: 20 --- # PRD — Session State Consolidation diff --git a/docs/epics/overnight-orchestration/README.md b/docs/epics/overnight-orchestration/README.md index 8d82cad..b6b90b4 100644 --- a/docs/epics/overnight-orchestration/README.md +++ b/docs/epics/overnight-orchestration/README.md @@ -2,7 +2,7 @@ title: Overnight Orchestration slug: overnight-orchestration milestone: "0.4.0" -status: in_progress +status: completed started: 2026-07-27 owner: Suprie description: > diff --git a/docs/epics/selasar-revamp/prd-rebrand-tokens.md b/docs/epics/selasar-revamp/prd-rebrand-tokens.md index 25a2504..9ad2d60 100644 --- a/docs/epics/selasar-revamp/prd-rebrand-tokens.md +++ b/docs/epics/selasar-revamp/prd-rebrand-tokens.md @@ -2,7 +2,7 @@ prd: prd-rebrand-tokens epic: selasar-revamp milestone: "0.5.0" -status: proposed +status: completed description: > Replace the app's Tailwind-v4 design tokens with the mockup's paper/ink/ teak palette and serif display font (light default, full dark pair), and @@ -86,7 +86,7 @@ occurrence sites are Phase 1/Phase 2 outputs, not decided yet._ ### Phase 3 — Verification -- [x] Visual pass in light, dark, and system-auto against every existing +- [x] `selasar-revamp/visual-pass-in-light-dark-and-system-auto-against-every-existing` Visual pass in light, dark, and system-auto against every existing screen (Dashboard, ProjectDetail tabs, Settings, Activity, Loops, Decisions, Epics), confirming no contrast regression on destructive, warning, or focus states. diff --git a/index.html b/index.html index 3a19281..5843b4b 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ Selasar +
From f12e790243fb33bed567f3a120866b5b1f3411fc Mon Sep 17 00:00:00 2001 From: Suprie Date: Wed, 12 Aug 2026 10:10:35 +0700 Subject: [PATCH 2/3] Project Rail: 72px door strip, RunState glow, pin/unpin, settings gear Replaces AppShell.tsx's feature-first sidebar with a 72px project rail (Rail.tsx): one door per registered project (2-letter initials, last-active-first), each door's glow tied to that project's RunState plus a placeholder night-run badge, a right-click pin/unpin affordance with pinned-only+overflow rail logic past 5 projects, and the Settings entry point moved to a gear icon at the rail's foot. Covers prd-rail-corridor-shell Phase 1 in full (4 queued loops, run as one combined overnight batch). --- .loopdeck/decisions.md | 7 + .loopdeck/loops.md | 6 +- .../selasar-revamp/prd-rail-corridor-shell.md | 19 +- src-tauri/src/commands/project.rs | 28 +++ src-tauri/src/config.rs | 7 + src-tauri/src/lib.rs | 1 + src/components/layout/AppShell.tsx | 142 ++------------- src/components/layout/Rail.tsx | 169 ++++++++++++++++++ src/hooks/useNightRunStatuses.ts | 45 +++++ src/hooks/useProjects.ts | 19 ++ src/lib/rail.ts | 54 ++++++ src/lib/tauri.ts | 9 + src/router.tsx | 26 +-- src/types/index.ts | 7 + tests/frontend/rail.test.mjs | 102 +++++++++++ 15 files changed, 481 insertions(+), 160 deletions(-) create mode 100644 src/components/layout/Rail.tsx create mode 100644 src/hooks/useNightRunStatuses.ts create mode 100644 src/lib/rail.ts create mode 100644 tests/frontend/rail.test.mjs diff --git a/.loopdeck/decisions.md b/.loopdeck/decisions.md index 443adb1..ab7b018 100644 --- a/.loopdeck/decisions.md +++ b/.loopdeck/decisions.md @@ -2,6 +2,13 @@ _Older decisions archived to [decisions-archive.md](./decisions-archive.md)._ +## 2026-08-12 — Rail pin state is a plain per-machine bool; night-run badge is a run-plan-derived placeholder + +- **Status**: accepted +- **Context**: `prd-rail-corridor-shell` Phase 1's two open questions (pin trigger UI, pin storage) and its dependency on `prd-detail-drawer`'s not-yet-run night-run spike were pre-answered at run queue time, not left for this loop to guess. +- **Consequences**: Pin/unpin lives on the door itself as a right-click context menu (no room card exists until Phase 2) — a plain `pinned: bool` on `ProjectEntry` (`config.rs`), mirroring the existing `autonomous` flag exactly, no new storage design. The night-run badge derives from whether the project has an active/queued `.loopdeck/run-plan.yaml` (`hasActiveOrQueuedRun` in `src/lib/rail.ts`) — an explicit placeholder, not the real `RunState` variant `prd-night-run-surfaces` may eventually add once its drawer spike lands. +- **Detail**: `.loopdeck/loops.md` `## Current` (2026-08-12 entry) has the full file/symbol breakdown. + ## 2026-08-04 — Run-queue reliability: verdict marker must survive to the final message; branch names are now descriptive - **Status**: accepted diff --git a/.loopdeck/loops.md b/.loopdeck/loops.md index 05e4933..45ec8b1 100644 --- a/.loopdeck/loops.md +++ b/.loopdeck/loops.md @@ -2,7 +2,11 @@ ## Current -- **Started**: 2026-08-05 +- **Started**: 2026-08-12 +- **Goal**: `selasar-revamp` / `prd-rail-corridor-shell` Phase 1 (all 4 loops, overnight run) — complete. +- **Status**: completed +- ✅ **`prd-rail-corridor-shell` Phase 1 — Project rail, all 4 loops** (2026-08-12) — New 72px `Rail.tsx` (`src/components/layout/`) replacing `AppShell.tsx`'s feature-first sidebar: one door per registered project (2-letter initials, `src/lib/rail.ts::doorInitials`), sorted last-active-first (`sortByLastActive`). Door glow ties to `RunState` (idle/working/waiting/done) via a tone→CSS-var map + `color-mix` box-shadow. Night-run badge is a placeholder per the run's own pre-answered clarification — derived from an active/queued `.loopdeck/run-plan.yaml` (`hasActiveOrQueuedRun`, polled every 5s per visible door via new `useNightRunStatuses` hook + existing `getRunStatus`), pending `prd-detail-drawer`'s spike for the real representation. Pin/unpin is a right-click context menu on the door (Radix `DropdownMenu` driven by state, not its default click-trigger, so left-click still navigates); backed by a new `pinned: bool` on `ProjectEntry` (`config.rs`) + `set_project_pinned` command, mirroring the existing `autonomous` flag exactly. Rail shows all doors at ≤5 projects; past 5, pinned-only + one fixed overflow door back to the corridor — falling back to the 5 most recently active when nobody has pinned yet (`selectRailDoors`). Settings moved to a gear icon at the rail's foot (`/settings` route unchanged); the old sidebar's brand mark and "Local only v0.2.0" footer were dropped per the run's answers, with the version badge relocated into the shared header instead (visible on every page, not just the rail). `onNavClick` prop plumbing (AppShell/router.tsx) deleted as dead code — its only branch (`/import`) had no live caller since the Dashboard redesign replaced that nav link with a dropdown. 8 new frontend tests (`tests/frontend/rail.test.mjs`, pure logic — `doorInitials`/`sortByLastActive`/`selectRailDoors`/`hasActiveOrQueuedRun`) since this repo's `node:test` runner has no DOM. Gates green: fmt/clippy(lib, `-D warnings`)/test(596 passed, unchanged — no new Rust tests, matching the untested `set_project_autonomous` precedent)/`tsc --noEmit`/`npm run build`. **Not verified live**: this is a Tauri desktop app with no `_electron`/Playwright driver in this repo and no browser-mode IPC mock, so a real click-through (door glow colors, right-click pin menu, overflow threshold) needs a human with `npm run tauri dev` — same documented gap as every prior UI-only loop in this file. +- **Previous** (2026-08-05): - **Goal**: `selasar-revamp` / `prd-rebrand-tokens` — complete (all 3 phases). Phase 2 rebrand: full-repo audit + rename of window title, wordmark, and in-app copy to "Selasar" (`.loopdeck/` + `project.yaml` untouched). Phase 3 verification: visual pass in light/dark/system-auto over every existing screen found and fixed 6 sub-4.5:1 WCAG contrast defects in `src/styles.css` (light warning/success/destructive darkened, dark muted-foreground + primary lightened, dark destructive-foreground → ink), and `npx tsc --noEmit` clean with "Selasar" statically confirmed in the window title bar. - **Status**: completed - ✅ **PRD delivery order — `order:` frontmatter, migrate/reorder commands, and two epic-sort bug fixes** (2026-08-06, ad-hoc user request, not tied to a docs/epics PRD, branch `claude/debug-vite-components-a36688`) — Added optional `order: u32` PRD frontmatter (`epic.rs`); `read_prds` sort key is `order` → README `## PRD Index` table position → filename, read-only (a first version auto-backfilled on read and corrupted this repo's own `docs/epics/**/*.md` via `cargo test`'s dogfood test — reverted). Two new explicit commands: `migrate_prd_order` (backfill missing values only, idempotent) and `set_prd_order` (full drag-to-reorder rewrite, permutation-validated). New `PrdReorderDialog.tsx` wired into `EpicsPanel.tsx` (reorder button before the edit pencil) — built on plain pointer events, not HTML5 `draggable`, after discovering Tauri's window-level drag-drop (used by `Chat.tsx`/`ImportFlow.tsx`) silently swallows `dragover`/`drop`. Also fixed two real epic-ordering bugs surfaced along the way: `EpicsView.tsx`'s cross-project milestone merge wasn't actually sorted (stale comment claimed it was), and `EpicsPanel.tsx`'s per-project epic list had no sort at all. Both now use a shared `sortEpics` helper (`lib/utils.ts`) — status-primary (`in_progress` → `proposed` → `abandoned` → `completed`), milestone ascending (numeric) secondary. Gates green: `cargo test` 596 passed (61 in `epic.rs`), `tsc --noEmit` clean. See decision of same date. Not verified live in the running app — Tauri IPC needs the real webview, and the dev-server port was held by another session throughout. diff --git a/docs/epics/selasar-revamp/prd-rail-corridor-shell.md b/docs/epics/selasar-revamp/prd-rail-corridor-shell.md index 350ebc2..6983651 100644 --- a/docs/epics/selasar-revamp/prd-rail-corridor-shell.md +++ b/docs/epics/selasar-revamp/prd-rail-corridor-shell.md @@ -62,24 +62,29 @@ on the existing routed `ProjectDetail` page as an interim target. ## Design -_Stub — pin/unpin trigger placement and the default rail state for an -existing install crossing 5 projects with zero pins are Phase 1 decisions, -not made yet._ +Phase 1 decisions resolved during implementation (2026-08-12): pin/unpin is a +right-click context menu on the door itself (no room card exists yet this +phase); an existing install crossing 5 projects with zero pins shows the 5 +most recently active as a fallback rather than an empty rail; pin state is a +plain `bool` on the global registry's `ProjectEntry` (per-machine, not +synced). The night-run indicator is a placeholder — derived from whether the +project has an active/queued `RunPlan` (existing run-queue data), pending +`prd-detail-drawer`'s spike settling the real `RunState` representation. ## Phases ### Phase 1 — Project rail -- [ ] Build a `Rail` component (72px icon strip, one door per project, 2-letter +- [x] Build a `Rail` component (72px icon strip, one door per project, 2-letter initials) replacing `AppShell.tsx`'s current sidebar markup. -- [ ] Wire each door's glow color to that project's `RunState` +- [x] Wire each door's glow color to that project's `RunState` (working/waiting/done/idle) plus a distinct night-run indicator, reusing the state derivation already in `AttentionPanel.tsx` / `useAttentionItems`. -- [ ] Add a pin/unpin affordance per project, and rail logic that shows all +- [x] Add a pin/unpin affordance per project, and rail logic that shows all projects at 5 or fewer, or pinned-only plus one overflow door (back to the corridor) past 5. -- [ ] Move the settings entry point to a gear icon at the rail's foot, +- [x] Move the settings entry point to a gear icon at the rail's foot, preserving the existing `/settings` route. ### Phase 2 — Corridor room-card list diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 31a6963..6e4fcac 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -97,6 +97,7 @@ async fn register_directory( uncommitted: git_info.uncommitted.into(), run_state: RunState::Idle, autonomous: false, + pinned: false, next_steps_total, next_steps_done, }; @@ -385,6 +386,33 @@ pub async fn set_project_autonomous( Ok(()) } +/// Set the per-project rail-pin flag in the config registry. +/// +/// Past 5 registered projects, the 72px rail shows pinned projects only (plus +/// a fixed overflow door back to the corridor) — see `prd-rail-corridor-shell` +/// Phase 1. +#[tauri::command] +pub async fn set_project_pinned( + path: String, + pinned: bool, + state: State<'_, AppState>, +) -> Result<(), AppError> { + debug!("set_project_pinned called for path: {path}, pinned: {pinned}"); + let root = { + let config = state.config.lock().map_err(|_| AppError::LockError)?; + paths::resolve_registered_root(&config, &path)? + }; + { + let mut config = state.config.lock().map_err(|_| AppError::LockError)?; + if let Some(entry) = config.find_by_path_mut(&root) { + entry.pinned = pinned; + config.save()?; + } + } + info!("set_project_pinned complete for: {path} → {pinned}"); + Ok(()) +} + /// Remove a project from the registry. /// Does NOT delete the `.loopdeck/` directory or any project files. #[tauri::command] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 92b6175..de60fca 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -228,6 +228,12 @@ pub struct ProjectEntry { /// `skip_serializing_if` keeps the registry tidy for the common case. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub autonomous: bool, + /// Pinned to the rail (`prd-rail-corridor-shell` Phase 1): past 5 + /// registered projects, only pinned projects (plus a fixed overflow door) + /// show in the 72px rail. Per-machine, stored in the global registry — + /// not synced. Older configs without this field deserialize to `false`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub pinned: bool, /// Total `## Next Steps` checklist items parsed from `.loopdeck/loops.md`. /// Re-read at every `list_projects`/`import_project` call (same treatment /// as `current_loop`: doesn't gate whether a save happens, but is always @@ -272,6 +278,7 @@ impl Default for ProjectEntry { uncommitted: UncommittedStats::default(), run_state: RunState::Idle, autonomous: false, + pinned: false, next_steps_total: 0, next_steps_done: 0, } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d46e90..f942d5e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -167,6 +167,7 @@ pub fn run() { commands::project::get_project, commands::project::update_description, commands::project::set_project_autonomous, + commands::project::set_project_pinned, commands::project::remove_project, commands::project::open_in_finder, commands::project::open_in_terminal, diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 7225651..abced82 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,75 +1,11 @@ -import { Link, useRouterState } from "@tanstack/react-router"; -import { - Command, - LayoutDashboard, - Activity, - Bot, - GitBranch, - RotateCw, - Layers, - Settings, - Sun, - Moon, - Monitor, -} from "lucide-react"; +import { Sun, Moon, Monitor } from "lucide-react"; import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; import { useTheme } from "@/lib/theme"; import { useAppStore } from "@/store/appStore"; import { CommandPalette } from "./CommandPalette"; - -const navGroups = [ - { - label: "Work", - items: [ - { to: "/", label: "Overview", icon: LayoutDashboard, exact: true }, - { to: "/activity", label: "Activity", icon: Activity }, - { to: "/agent", label: "Agent Runner", icon: Bot }, - ], - }, - { - label: "Memory", - items: [ - { to: "/decisions", label: "Decisions", icon: GitBranch }, - { to: "/loops", label: "Loops", icon: RotateCw }, - { to: "/epics", label: "Epics", icon: Layers }, - ], - }, -] as const; - -function NavLink({ - to, - label, - icon: Icon, - exact, - pathname, - onNavClick, -}: { - to: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - exact?: boolean; - pathname: string; - onNavClick?: (to: string, e: React.MouseEvent) => void; -}) { - const active = exact ? pathname === to : pathname === to || pathname.startsWith(to + "/"); - return ( - onNavClick?.(to, e)} - className={cn( - "relative flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors", - active - ? "nav-active-bar bg-accent text-foreground font-medium" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - - {label} - - ); -} +import { Rail } from "./Rail"; function ThemeToggle() { const { theme, setTheme } = useTheme(); @@ -105,79 +41,31 @@ function ThemeToggle() { } /** - * Application shell: sidebar + content area. + * Application shell: 72px project rail + content area. * * Designed to wrap a router `` as children (rendered by the root - * route in `src/router.tsx`). `onNavClick` lets the host intercept a nav - * click (used by the Tauri shell to open a native folder dialog for /import - * instead of routing). + * route in `src/router.tsx`). The rail (`Rail.tsx`) replaced the old + * feature-first sidebar nav — see `prd-rail-corridor-shell` Phase 1; its + * brand mark and "Local only vX" footer were dropped rather than carried + * onto the rail, with the version badge moved into this shared header + * instead so it stays visible on every page, not just the rail's foot. */ -export function AppShell({ - children, - onNavClick, -}: { - children: ReactNode; - onNavClick?: (to: string, e: React.MouseEvent) => void; -}) { - const pathname = useRouterState({ select: (s) => s.location.pathname }); +export function AppShell({ children }: { children: ReactNode }) { const isLoading = useAppStore((s) => s.isLoading); return (
- +
+
+ + Local only + v0.2.0 +
= { + idle: null, + working: "var(--primary)", + waiting: "var(--destructive)", + done: "var(--success)", +}; + +function doorStyle(tone: string | null): React.CSSProperties { + if (!tone) return {}; + return { + boxShadow: `0 0 0 1.5px ${tone}, 0 0 10px color-mix(in oklab, ${tone} 45%, transparent)`, + background: `color-mix(in oklab, ${tone} 14%, var(--surface))`, + }; +} + +const RUN_STATE_TEXT: Record = { + idle: "text-muted-foreground", + working: "text-primary", + waiting: "text-destructive", + done: "text-success", +}; + +function Door({ + project, + active, + nightRun, + onSelect, +}: { + project: ProjectEntry; + active: boolean; + nightRun: boolean; + onSelect: () => void; +}) { + const { setPinned } = useProjects(); + const [menuOpen, setMenuOpen] = useState(false); + const tone = RUN_STATE_TONE[project.run_state]; + + return ( + +
+ {/* Invisible, click-through anchor: positions the menu at the door + * without hijacking left-click (Radix's real Trigger opens on any + * click — pin/unpin is right-click only per the PRD's Phase 1 + * decision, so we drive `open` ourselves from onContextMenu below). */} + + + + +
+ + setPinned(project.path, !project.pinned)}> + {project.pinned ? ( + <> + Unpin from rail + + ) : ( + <> + Pin to rail + + )} + + +
+ ); +} + +/** + * 72px project rail — one door per registered project, replacing + * `AppShell.tsx`'s old feature-first sidebar nav. See + * `docs/epics/selasar-revamp/prd-rail-corridor-shell.md` Phase 1. + */ +export function Rail() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const navigate = useNavigate(); + const projects = useAppStore((s) => s.projects); + const setSelectedProjectPath = useAppStore((s) => s.setSelectedProjectPath); + const { doors, overflow } = selectRailDoors(projects); + const nightRunStatuses = useNightRunStatuses(doors.map((p) => p.path)); + + const openProject = (path: string) => { + setSelectedProjectPath(path); + navigate({ to: "/project/$projectPath", params: { projectPath: encodeURIComponent(path) } }); + }; + + return ( + + ); +} diff --git a/src/hooks/useNightRunStatuses.ts b/src/hooks/useNightRunStatuses.ts new file mode 100644 index 0000000..a34a5a6 --- /dev/null +++ b/src/hooks/useNightRunStatuses.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import * as api from "../lib/tauri"; +import { hasActiveOrQueuedRun } from "../lib/rail"; + +const POLL_MS = 5000; + +/** + * Polls `getRunStatus` for each given project path, returning which ones + * have an active or queued overnight run — the rail's night-run door badge + * (`prd-rail-corridor-shell` Phase 1). Same poll cadence `RunQueuePanel` + * already uses for a single project, fanned out to every visible door. + */ +export function useNightRunStatuses(paths: string[]): Record { + const key = paths.join("|"); + const [statuses, setStatuses] = useState>({}); + + useEffect(() => { + if (!key) { + setStatuses({}); + return; + } + let disposed = false; + const load = async () => { + const entries = await Promise.all( + key.split("|").map(async (path) => { + try { + const status = await api.getRunStatus(path); + return [path, hasActiveOrQueuedRun(status)] as const; + } catch { + return [path, false] as const; + } + }), + ); + if (!disposed) setStatuses(Object.fromEntries(entries)); + }; + load(); + const id = setInterval(load, POLL_MS); + return () => { + disposed = true; + clearInterval(id); + }; + }, [key]); + + return statuses; +} diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index 371c122..aba2dc8 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -221,6 +221,24 @@ export function useProjects() { [setError, updateProjectInStore], ); + /** Toggle a project's rail pin and reflect it in the store. */ + const setPinned = useCallback( + async (path: string, pinned: boolean) => { + setError(null); + try { + await api.setProjectPinned(path, pinned); + const existing = useAppStore.getState().projects.find((p) => p.path === path); + if (existing) { + updateProjectInStore({ ...existing, pinned }); + } + } catch (err) { + const appErr = err as AppError; + setError(appErr.message ?? String(err)); + } + }, + [setError, updateProjectInStore], + ); + /** Open a path in the system terminal. */ const openInTerminal = useCallback( async (path: string) => { @@ -244,6 +262,7 @@ export function useProjects() { regenerateDesc, rescanProject, setAutonomous, + setPinned, openInFinder, openInTerminal, }; diff --git a/src/lib/rail.ts b/src/lib/rail.ts new file mode 100644 index 0000000..4187274 --- /dev/null +++ b/src/lib/rail.ts @@ -0,0 +1,54 @@ +import type { ProjectEntry, RunQueueStatus } from "../types"; + +/** Rail doors past this count collapse to pinned-only + one overflow door. */ +export const RAIL_DOOR_LIMIT = 5; + +/** First 2 characters of the project name, uppercased — the door's initials. */ +export function doorInitials(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +/** Most recently opened first. Never-opened projects sort last. */ +export function sortByLastActive(projects: ProjectEntry[]): ProjectEntry[] { + return [...projects].sort((a, b) => { + const ta = a.last_opened ? Date.parse(a.last_opened) : -Infinity; + const tb = b.last_opened ? Date.parse(b.last_opened) : -Infinity; + return tb - ta; + }); +} + +/** + * Which projects render as rail doors, and whether a fixed overflow door + * (back to the corridor) is needed. + * + * - `<= RAIL_DOOR_LIMIT` registered projects: every door shows, no overflow. + * - `> RAIL_DOOR_LIMIT`: pinned projects only, plus overflow. If nobody has + * pinned anything yet (a fresh install crossing 5 projects), falls back to + * the 5 most recently active instead of an empty rail. + */ +export function selectRailDoors(projects: ProjectEntry[]): { + doors: ProjectEntry[]; + overflow: boolean; +} { + const sorted = sortByLastActive(projects); + if (sorted.length <= RAIL_DOOR_LIMIT) { + return { doors: sorted, overflow: false }; + } + const pinned = sorted.filter((p) => p.pinned); + return { + doors: pinned.length > 0 ? pinned : sorted.slice(0, RAIL_DOOR_LIMIT), + overflow: true, + }; +} + +/** + * Whether a project has an overnight run in flight or queued — the rail's + * placeholder night-run signal (prd-night-run-surfaces owns the real one, + * pending its detail-drawer spike). Derived from the same run-plan data + * `RunQueuePanel` already polls, not a new backend concept. + */ +export function hasActiveOrQueuedRun(status: RunQueueStatus | undefined): boolean { + if (!status) return false; + if (status.active) return true; + return status.plan?.phases.some((p) => p.status === "queued" || p.status === "running") ?? false; +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 7d7bd42..4e84cd1 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -105,6 +105,15 @@ export async function setProjectAutonomous( return invoke("set_project_autonomous", { path, autonomous }); } +/** + * Toggle a project's rail pin. Past 5 registered projects, only pinned + * projects show as rail doors. + * Rust: set_project_pinned(path: String, pinned: bool) -> Result<(), AppError> + */ +export async function setProjectPinned(path: string, pinned: boolean): Promise { + return invoke("set_project_pinned", { path, pinned }); +} + /** * Remove a project from the registry (does NOT delete files). * Rust: remove_project(path: String) -> Result<(), AppError> diff --git a/src/router.tsx b/src/router.tsx index c94819f..9ea82be 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -11,7 +11,6 @@ import { Link, } from "@tanstack/react-router"; import { useAppStore } from "./store/appStore"; -import { useProjects } from "./hooks/useProjects"; import { AppShell } from "./components/layout/AppShell"; // ── View imports ───────────────────────────────────────────────────────────── @@ -32,32 +31,9 @@ import { SpecEditorPage } from "./components/spec/SpecEditorPage"; function AppShellLayout() { const error = useAppStore((s) => s.error); const setError = useAppStore((s) => s.setError); - const { scanFolder } = useProjects(); - - /** The "Import Repo" nav item triggers a native folder dialog instead of - * routing to /import directly. The shared AppShell owns the link markup; we - * pass a handler keyed by destination so this Tauri-only behaviour stays - * out of the design-system shell. */ - const handleNavClick = async (to: string, e: React.MouseEvent) => { - if (to !== "/import") return; - e.preventDefault(); - try { - const { open } = await import("@tauri-apps/plugin-dialog"); - const selected = await open({ - directory: true, - multiple: false, - title: "Select Folder to Scan", - }); - if (selected && typeof selected === "string") { - await scanFolder(selected); - } - } catch { - // User cancelled or dialog failed. - } - }; return ( - + {/* Error banner — kept from the previous shell */} {error && (
diff --git a/src/types/index.ts b/src/types/index.ts index ce18189..2874e84 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -104,6 +104,13 @@ export interface ProjectEntry { * the backend `skip_serializing_if`s it when false. */ autonomous?: boolean; + /** + * Pinned to the rail (`prd-rail-corridor-shell` Phase 1): past 5 registered + * projects, only pinned projects (plus a fixed overflow door) show in the + * 72px rail. Per-machine, not synced. Optional on the wire because the + * backend `skip_serializing_if`s it when false. + */ + pinned?: boolean; /** * Total `## Next Steps` checklist items parsed from `.loopdeck/loops.md`, * refreshed on every `list_projects` call. Ephemeral — not persisted when diff --git a/tests/frontend/rail.test.mjs b/tests/frontend/rail.test.mjs new file mode 100644 index 0000000..724b9ba --- /dev/null +++ b/tests/frontend/rail.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + doorInitials, + sortByLastActive, + selectRailDoors, + hasActiveOrQueuedRun, + RAIL_DOOR_LIMIT, +} from "../../src/lib/rail.ts"; + +function project(overrides = {}) { + return { + path: "/tmp/x", + name: "x", + description: "", + status: "active", + last_opened: null, + created_at: "2026-01-01T00:00:00Z", + last_commit_date: null, + last_commit_message: null, + last_modified: null, + uncommitted: { files: 0, added: 0, deleted: 0 }, + run_state: "idle", + pinned: false, + next_steps_total: 0, + next_steps_done: 0, + ...overrides, + }; +} + +test("doorInitials takes the first 2 chars, uppercased", () => { + assert.equal(doorInitials("loopdeck"), "LO"); + assert.equal(doorInitials("my-cool-app"), "MY"); + assert.equal(doorInitials("a"), "A"); +}); + +test("sortByLastActive orders most recently opened first, never-opened last", () => { + const older = project({ path: "/a", last_opened: "2026-01-01T00:00:00Z" }); + const newer = project({ path: "/b", last_opened: "2026-02-01T00:00:00Z" }); + const never = project({ path: "/c", last_opened: null }); + const sorted = sortByLastActive([older, never, newer]); + assert.deepEqual( + sorted.map((p) => p.path), + ["/b", "/a", "/c"], + ); +}); + +test("selectRailDoors shows every project with no overflow at or under the limit", () => { + const projects = Array.from({ length: RAIL_DOOR_LIMIT }, (_, i) => project({ path: `/p${i}` })); + const { doors, overflow } = selectRailDoors(projects); + assert.equal(doors.length, RAIL_DOOR_LIMIT); + assert.equal(overflow, false); +}); + +test("selectRailDoors falls back to the 5 most recently active when nobody has pinned yet", () => { + const projects = Array.from({ length: RAIL_DOOR_LIMIT + 3 }, (_, i) => + project({ path: `/p${i}`, last_opened: `2026-01-${String(i + 1).padStart(2, "0")}T00:00:00Z` }), + ); + const { doors, overflow } = selectRailDoors(projects); + assert.equal(overflow, true); + assert.equal(doors.length, RAIL_DOOR_LIMIT); + // Most recent 5 = the last 5 pushed (highest day-of-month). + assert.deepEqual( + doors.map((p) => p.path), + ["/p7", "/p6", "/p5", "/p4", "/p3"], + ); +}); + +test("selectRailDoors shows pinned-only plus overflow once pins exist", () => { + const projects = Array.from({ length: RAIL_DOOR_LIMIT + 3 }, (_, i) => project({ path: `/p${i}` })); + projects[0].pinned = true; + projects[2].pinned = true; + const { doors, overflow } = selectRailDoors(projects); + assert.equal(overflow, true); + assert.deepEqual( + doors.map((p) => p.path).sort(), + ["/p0", "/p2"], + ); +}); + +test("hasActiveOrQueuedRun is false with no plan", () => { + assert.equal(hasActiveOrQueuedRun(undefined), false); + assert.equal(hasActiveOrQueuedRun({ plan: null, active: false }), false); +}); + +test("hasActiveOrQueuedRun is true when active or a phase is queued/running", () => { + assert.equal(hasActiveOrQueuedRun({ plan: null, active: true }), true); + assert.equal( + hasActiveOrQueuedRun({ + plan: { phases: [{ status: "completed" }, { status: "queued" }] }, + active: false, + }), + true, + ); + assert.equal( + hasActiveOrQueuedRun({ + plan: { phases: [{ status: "completed" }, { status: "failed" }] }, + active: false, + }), + false, + ); +}); From 6499e8880d02c0e0fb93fdb5c371bd9925f9ec10 Mon Sep 17 00:00:00 2001 From: Suprie Date: Wed, 12 Aug 2026 10:10:58 +0700 Subject: [PATCH 3/3] Record draft PR #84 in loops.md Next Steps --- .loopdeck/loops.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.loopdeck/loops.md b/.loopdeck/loops.md index 45ec8b1..7b16cef 100644 --- a/.loopdeck/loops.md +++ b/.loopdeck/loops.md @@ -68,6 +68,7 @@ - **Independent delivery (2026-07-27, worktree `loopdeck-dashboard-redesign-effort-69cb9b`)**: Redesigned the Dashboard to match a reference mockup (`LoopDeck 0.3.1 — Workspace Dashboard Mockup.html`, user-supplied). New top bar with a real ⌘K command palette (`CommandPalette.tsx`, built on the previously-installed-but-unwired `ui/command.tsx`/`cmdk` — closes the P6 backlog item that flagged the old decorative ⌘K hint as misleading); sidebar nav regrouped into Work/Memory sections with a "Local only · v0.2.0" status footer (`AppShell.tsx`). New "Needs attention" panel (`AttentionPanel.tsx`) sourced from live state only — pending permission/question/plan prompts (`usePendingInteractions`) and `run_state === "working"` projects — renders nothing when empty, no filler card. Project grid replaced with a project list (`ProjectList.tsx`, `ProjectCard.tsx` deleted — its only consumer was `Dashboard.tsx`) showing a derived state pill (Waiting/Working/Ready/Idle) and a state-dependent focus line. New "Today" timeline (`TodayPanel.tsx`) shares a newly-extracted `useActivityEvents` hook (`src/hooks/useActivityEvents.tsx`) with `ActivityFeed.tsx`, deduping ~150 lines of turn/decision/loop collector logic that was previously inline only in the Activity page. Backend: `ProjectEntry` gained ephemeral `next_steps_total`/`next_steps_done` fields (`config.rs`), parsed from `.loopdeck/loops.md`'s Next Steps checklist via the existing `memory::parse_loops` and wired into `import_project`/`list_projects`'s existing blocking closures alongside the `current_loop` read — same consistency level as that field; `rescan_project`/`get_project` intentionally left untouched (they don't refresh `current_loop` live either). No re-theme was needed — the app's existing violet/oklch design tokens already matched the mockup's palette intent. Scoped out per the plan: the mockup's mobile bottom-nav bar (desktop Tauri app), its non-functional "Filter ⌄" button, and its fabricated "N agents available"/per-card elapsed-time stats (replaced with real derived counts, or dropped where no real data exists). **Verified:** `cargo fmt --check` / `cargo clippy --lib -- -D warnings` / `cargo test --lib` (436 passed) all green; `tsc --noEmit` and `npm run build` clean; visually smoke-tested via the Vite dev server (shell layout, sidebar grouping, command palette open + navigate, light/dark theme toggle all confirmed working). **Not verified:** real project-data rendering end-to-end (`invoke()` rejects outside the native Tauri shell, so `list_projects` etc. can't be exercised from a plain browser) — needs a manual `npm run tauri dev` pass. ## Next Steps +- [ ] Review & merge: https://github.com/suprie/loopdeck/pull/84 (Project Rail — prd-rail-corridor-shell Phase 1) - [ ] Review & merge: https://github.com/suprie/loopdeck/pull/71 - [ ] Review & merge: https://github.com/suprie/loopdeck/pull/66 (docs-accuracy: audit + rewrite PRD.md/CLAUDE.md) - [ ] Review & merge: https://github.com/suprie/loopdeck/pull/new/feat/prd-wake-up