diff --git a/README.md b/README.md index 6e90a46..4a98b4a 100644 --- a/README.md +++ b/README.md @@ -4,84 +4,85 @@ # DevDeck -**A command deck for everyone juggling a pile of Claude Code, Codex & Antigravity projects.** +**A local-first command center for Claude Code, Codex, and Antigravity projects.** -See every repo's state at a glance — git status, how long it's been neglected, your Claude Code, Codex, and Antigravity session history — and jump back in with one click (`claude -c` / `codex resume --last` / `agy -c`). +See every repository, live agent session, next task, local usage estimate, and resume cue in one stable workspace. ![License](https://img.shields.io/badge/license-MIT-blue) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6) ![Built with Electron](https://img.shields.io/badge/Electron-43-47848F) -![Tests](https://img.shields.io/badge/tests-847%20passing-3fb950) +![Tests](https://img.shields.io/badge/tests-878%20passing-3fb950) ![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg) -DevDeck demo + + +## What DevDeck solves + +AI coding work gets fragmented quickly: one repository is waiting for a response, another has uncommitted work, and a third has a conversation you meant to resume. DevDeck keeps those contexts together without uploading your project or transcript data. + +The interface is organized as one command center: +- An expanded 224px sidebar contains Quick Open, primary views, urgency-grouped live sessions, and compact projects. +- The sidebar collapses to 52px on desktop and becomes a Sessions/Needs You drawer at narrow widths. +- The main project overview defaults to a row-first status board with localized text-and-shape status; cards remain optional. +- Selecting a session focuses its existing embedded terminal without respawning or remounting it. +- Project Memory opens as a right-side drawer, or a full-width sheet on narrow windows. + +
+DevDeck row-first project command center
-## Why - -If you run Claude Code across a dozen side projects, you lose the thread: *Which repos have uncommitted work? Which have I not touched in weeks? What was I even doing in that one?* DevDeck is a always-on desktop deck that answers those at a glance and gets you back into a session in one click — without touching your code or files. - -## Features - -- **🗂 Project deck** — every git repo under your scan locations as a card: branch, uncommitted count, last commit, AI session count. -- **➕ New project** — spin up a project without leaving the deck: pick a scan location, name it, and DevDeck creates the folder, runs `git init`, and opens it in a terminal with your active agent. -- **🤖 Multi-agent (Claude Code, Codex & Antigravity)** — every project and task has a provider-aware split **Open** control. The primary action uses the selected AI: it focuses that AI's live session, continues its latest conversation, or starts one when no history exists. Open the adjacent menu to choose another installed AI, or use its **+ New session** action to always start a separate conversation. A toolbar switcher sets the default AI. -- **📂 Multiple scan locations** — point DevDeck at several folders to scan for repos, or add individual repos that live anywhere; each is auto-detected. -- **🚦 Staleness traffic-light** — fresh / warning / neglected, so dirty or abandoned repos surface themselves. -- **📡 Status pulse** — the toolbar shows live ⚠ needs-you / ◉ working counts across every open cockpit session, plus today's estimated cost; click a count to filter the deck down to just those projects. -- **▶ Predictable resume** — generic project and task actions always honor the AI you choose (`claude -c` / `codex resume --last` / `agy -c`); they never redirect to whichever provider happened to open the project before. A specific historical conversation remains pinned to its original provider so it can be resumed safely. -- **🖥 Cockpit (embedded terminals · Windows)** — on Windows, **Open** drops you straight into an in-app terminal instead of a pile of external windows. A searchable sidebar is **grouped by urgency** — needing-you and working sessions float above a dedicated **📌 pinned** group, with quieter turn/idle sessions below — and carries a count badge on the 🖥 icon (and an optional **tray icon alert**, off-by-default-able) so you can see "who's waiting on me" from any view. Each session shows its **model, active working time, and a per-session 🧠 context % that colors up as it nears the limit** — for Codex sessions the model and the model's *real* context window are read straight from the rollout, so the percentage is exact rather than measured against a setting — and you can **name** sessions, **pin** the ones you care about most, and run **several per repo**. Every row also carries a **💬 one-line summary of what that session is actually working on**, re-derived from its transcript each time the agent finishes a turn — its in-progress task, the headline of the last reply, or the files it just edited — so a session you have had open for days stays labeled without renaming it by hand. It works for **Claude Code and Codex** sessions alike (Codex rollouts are read from a bounded tail, so even a multi-GB one costs milliseconds). Prefer a real summary? Turn on **AI refinement** in Settings and each finished turn is condensed by **that session's own agent CLI** — a Claude session by `claude`, a Codex session by `codex` — so one vendor's quota is never spent on another's work, and the feature works whichever agent you installed (off by default, since it does spend usage). Each row carries its **provider mark** (Claude / Codex / Antigravity) in a fixed column and clamps long names (one line while the summary is shown) with the full name on hover or keyboard focus, so the 250px sidebar never has to grow. A session always **restores, restarts, and forks under the provider it was opened with**, whatever the global agent selection is now. The live agent terminal + branch · agent status bar fill the right, with selection **copy (Ctrl+C) / paste (Ctrl+V)** that doesn't clash with the agent's own Ctrl+C interrupt, and clicking an **image path printed in the output** (a screenshot a test just wrote, say) opens it straight in your OS viewer. Running 10+ projects no longer means a wall of shrinking tabs you have to click through to see which finished. Your open sessions are **remembered across restarts** — after a quit or crash the cockpit lists them as one-click **restorable** entries (re-attaching each to its own conversation, pins included). You can run **several sessions in the same repo at once** — a **+ New session** button forks another conversation; each is tracked separately and you can **name it** (double-click the name) so you remember what each one is doing. (macOS/Linux keep opening your external terminal.) -- **↩ Resume cue** — auto-reads the *last thing you asked* in each project's newest session (Claude Code, Codex, or Antigravity) and shows it in the note slot, so "where was I?" needs no typing. Click to adopt it as your note. -- **◷ Project Memory + resume snapshot** — open Memory from any project card or compact row to reconstruct where you stopped: the newest provider-owned conversation point, current branch/working tree, recent commits, next tasks, and project note appear above one bounded timeline of Claude, Codex, Antigravity, Git, task, and recent-open activity. It is calculated only when requested from local files, makes no AI or network call, and leaves the normal one-click **Open** path unchanged. -- **☑ Per-project tasks + deadlines** — every project holds its own checklist with optional due dates; deck cards show a compact `☑ done/total` badge (with an overdue count in red) that jumps straight to the board. -- **📋 "Next" task board** — all open tasks across every project on one board, grouped **overdue / today / this week / later / no date**: check them off, edit inline, set due dates, or add a task to any project without leaving the view. Switch to a **calendar view** to see due dates laid out across the month. -- **↑ Unpushed signal** — commits ahead of your remote, flagged on the card so unprotected work stands out. -- **{ } Open in editor** (VS Code) and **📁 open folder** straight from a card; the deck **auto-refreshes in place** while it's open — only the cards that changed update, so there's no flicker as you work. -- **🐙 Jump to GitHub** — projects with a `github.com` remote show a GitHub icon; click it to open the repo page in your browser. -- **📝 Per-project notes** — jot your next todo; it sticks with the card. -- **📊 Claude Code + Codex local analytics** — one page shows **combined, Claude, and Codex API-equivalent estimated costs** side by side, then lets you filter token, model, daily, and per-project details by provider. History is parsed locally from `~/.claude` and `~/.codex`; searches and deleted-project totals remain available. These public-rate estimates are not subscription bills. Antigravity is excluded from local cost analytics because it does not expose stable local model/token cost data. -- **📉 Live limits, all providers** — a one-line footer shows the single most urgent limit across every installed provider; **All usage** opens a dialog with each provider's own windows. **Claude Code:** 5-hour, weekly, any **model-scoped weekly quota** the API reports (rendered from the server's own model name — nothing is hardcoded), plan, and **Usage Credits** when present. **Codex:** primary/secondary rate-limit windows read through the installed official `codex app-server` (it owns its own auth; DevDeck never reads or exports Codex credentials). **Antigravity:** no supported programmatic quota read exists, so DevDeck shows the documented `/usage`, `/quota`, and `/credits` commands with a copy button instead of guessing. A provider that fails keeps its last-good numbers, labelled how old they are, and never blanks the others. -- **📌 Pin / 🙈 hide / 🔎 search / sort** — keep the deck focused. -- **☰ Card / list view** — toggle a dense list view — a compact status-board grid, one row per project — to scan many repos at a glance; your choice is remembered. -- **🌐 4 languages** — English, 한국어, 日本語, 中文. -- **⬆ Auto-update** — checks GitHub Releases on launch and offers an in-app, user-confirmed download + restart (Windows/Linux; macOS pending code-signing). -- **🚀 Start on Windows login** — optionally launch DevDeck when you sign in (Windows only; opt-in in Settings). -- **🔒 Local-first** — reads your local agent data and git, and the renderer sends nothing anywhere (`connect-src 'none'`). Outbound calls only ever go to first-party endpoints: the GitHub update check, and — when you're signed into Claude Code — a usage check to `api.anthropic.com` (the same endpoint Claude Code itself uses). Codex limits come from the locally installed official `codex app-server`, which performs its own first-party request; Antigravity makes **no** usage request at all. Credentials never cross IPC — the Claude OAuth token stays in the main process, Codex credentials are never read by DevDeck, and only normalized plan/percent/reset/state values reach the UI. If you're not signed in, no usage call is made. No account, no telemetry. -- System tray + global shortcut (`Ctrl+Alt+D`), frameless Discord-style title bar. +## Highlights + +- **Unified project and session navigation** — Needs You and Working sessions stay visible from Projects, Tasks, Usage, and Settings. Previous sessions expose restore, warning, pin, forget, and Restore All actions in the same shell. Quick Open filters both sessions and projects. +- **Row-first project overview** — resume cues lead each row; branch, working-tree state, providers, sessions, cost, tasks, GitHub, editor, folder, and provider-aware Open remain available. +- **Provider-aware Open** — focus a live session, continue the correct provider-owned conversation, start a fresh session, or explicitly choose another installed provider. +- **Cockpit on Windows** — embedded Claude Code, Codex, and Antigravity terminals with persistence, restart, fork, rename, pin, close confirmation, search, clipboard handling, clickable links and image paths, context percentage, and summaries. +- **External terminals on macOS and Linux** — the same project actions launch supported native terminal applications. +- **Project Memory** — an on-demand local snapshot of the latest conversation, Git state, recent commits, tasks, notes, and activity timeline. It makes no AI or network call. +- **Cross-project Tasks** — add, edit, complete, schedule, filter, and view tasks as a list or calendar. +- **Local usage analytics** — combined Claude Code and Codex token and API-equivalent cost estimates, provider filters, model/day/project breakdowns, and deleted-project history. +- **Live provider limits** — a fixed 26px footer summarizes the most urgent supported limit; the dialog keeps each provider independent and preserves last-good data. +- **Repository signals** — uncommitted files, unpushed commits, staleness, session history, resume cues, notes, pin/hide, GitHub, editor, and folder actions. +- **Four languages** — English, 한국어, 日本語, 中文. +- **Local-first security** — context isolation, sandboxing, strict CSP, no telemetry, and no project data sent by the renderer.
Cross-project task board -Combined Claude Code and Codex local cost analytics -Live limits for every installed provider +Combined Claude Code and Codex local analytics
-Cockpit sidebar: sessions grouped by urgency, each with its provider mark, model, context % and a one-line summary of what it is working on +DevDeck settings +Live limits for installed providers +
+Urgency-grouped sessions in the shared command-center sidebar
## Install -Grab the latest from [**Releases**](https://github.com/writingdeveloper/devdeck/releases/latest): +Download the latest build from [Releases](https://github.com/writingdeveloper/devdeck/releases/latest). -| OS | Download | First run (unsigned build) | -|----|----------|----------------------------| -| **Windows** | `DevDeck-…-Setup.exe` | SmartScreen → **More info → Run anyway** | -| **macOS** — Apple Silicon | `DevDeck-…-arm64.dmg` | Open once → **System Settings → Privacy & Security → Open Anyway** (see below) | -| **macOS** — Intel | `DevDeck-…-x64.dmg` | Same as above | -| **Linux** | `DevDeck-…-x86_64.AppImage` (portable) or `…-amd64.deb` | `chmod +x` the AppImage, then run | +| OS | Download | First run | +|---|---|---| +| Windows | `DevDeck-…-Setup.exe` | SmartScreen → More info → Run anyway | +| macOS Apple Silicon | `DevDeck-…-arm64.dmg` | Privacy & Security → Open Anyway | +| macOS Intel | `DevDeck-…-x64.dmg` | Privacy & Security → Open Anyway | +| Linux | `…-x86_64.AppImage` or `…-amd64.deb` | `chmod +x` for AppImage | -Builds are **unsigned** (no code-signing certificate yet), so the first launch needs the bypass above. Then open **Settings** and add the folders that hold your git repos — DevDeck scans nothing until you choose them; you can add several scan locations or pin individual repos. +Builds are currently unsigned. After launch, open Settings and add scan roots or individual project folders; DevDeck scans nothing until you choose them. -**macOS notes:** on macOS 15 (Sequoia) and later, right-click → Open no longer bypasses Gatekeeper for unsigned apps. Launch the app once (it will be blocked), then go to **System Settings → Privacy & Security** and click **Open Anyway** — macOS may ask you to repeat this once more. Also, the first time you open a project, macOS asks for permission to control **Terminal.app** ("DevDeck wants to control Terminal") — allow it, or opening sessions will silently do nothing. If you denied it, re-enable it under **System Settings → Privacy & Security → Automation**. +On macOS, allow Terminal automation when prompted. On macOS 15 or later, launch once and use System Settings → Privacy & Security → Open Anyway for the unsigned build. ## Platform support -| OS | Status | -|----|--------| -| Windows | ✅ Supported — Windows Terminal / PowerShell. Installer provided. | -| macOS | ✅ Supported — opens Terminal.app via `osascript`. `.dmg` provided (arm64 + x64). Launcher logic + AppleScript are validated on real macOS CI runners; GUI hardware-testing & signing still pending — feedback welcome. | -| Linux | ✅ Supported — auto-detects `gnome-terminal` / `konsole` / `alacritty` / `kitty` / `xterm` (and `x-terminal-emulator`). AppImage + `.deb` provided. | +| OS | Project actions | Embedded Cockpit | +|---|---|---| +| Windows | Windows Terminal / PowerShell | Yes | +| macOS | Terminal.app through `osascript` | No | +| Linux | Auto-detected supported terminal | No | + +Every release is built and unit-tested on Windows, macOS, and Linux CI runners. -> Every release is built **and** unit-tested on real Windows, macOS, and Linux GitHub Actions runners; the macOS `.dmg` is produced on a genuine Mac runner. +The command-center UI is also exercised in Electron at desktop and 520px widths across all four languages, followed by automated accessibility audits. ## Build from source @@ -89,20 +90,37 @@ Builds are **unsigned** (no code-signing certificate yet), so the first launch n git clone https://github.com/writingdeveloper/devdeck.git cd devdeck npm install -npm start # build + launch -npm test # run the test suite (Vitest) -npm run dist # package to release/win-unpacked (needs Windows Developer Mode for a clean run) +npm start +``` + +Useful checks: + +```bash +npm test +npm run build +npm run qa +npm run qa:audit ``` ## How it works -DevDeck scans your configured **scan locations** for git repos (walking org/repo layouts, plus any individual repos you add), reads each repo's git state, and cross-references your AI coding sessions — Claude Code (`~/.claude/projects`), Codex (`~/.codex/sessions`), or Antigravity (`~/.gemini/antigravity`). Everything runs in the Electron main process and stays on your machine — DevDeck only *reads* your data and *launches* a terminal; it never edits your project files. New agents plug in behind one `AgentProvider` interface. +DevDeck scans only the folders you authorize, reads Git state, and correlates local session data from: + +- Claude Code: `~/.claude/projects` +- Codex: `~/.codex/sessions` +- Antigravity: `~/.gemini/antigravity` + +Project scanning, transcript parsing, Git calls, terminal processes, and provider usage checks stay in the Electron main process. The sandboxed renderer receives normalized view models through guarded IPC. New agents plug in behind the existing provider interface. + +Outbound traffic is limited to first-party update or supported usage endpoints. DevDeck has no account system and no telemetry. Codex credentials are never read by DevDeck; its official app server owns its authentication. + +## Tech -**Tech:** Electron 43 · TypeScript · esbuild · Vitest · electron-updater. Hardened renderer (context isolation, sandbox, strict CSP). +Electron 43 · TypeScript · esbuild · Vitest · Playwright · axe-core · electron-updater ## Contributing -Issues and PRs welcome — especially **code-signing** (Windows/macOS), the top roadmap item: it removes the SmartScreen/Gatekeeper friction and unlocks macOS auto-update. This is an early project; expect rough edges. +Issues and pull requests are welcome. Code signing for Windows and macOS remains a priority because it removes first-run security friction and enables a smoother release path. ## License diff --git a/docs/screenshots/all-provider-usage.png b/docs/screenshots/all-provider-usage.png index 8e8873e..511faf5 100644 Binary files a/docs/screenshots/all-provider-usage.png and b/docs/screenshots/all-provider-usage.png differ diff --git a/docs/screenshots/cockpit-providers.png b/docs/screenshots/cockpit-providers.png index 48f5c25..50f3367 100644 Binary files a/docs/screenshots/cockpit-providers.png and b/docs/screenshots/cockpit-providers.png differ diff --git a/docs/screenshots/projects.png b/docs/screenshots/projects.png index 62a3008..7a6bb9f 100644 Binary files a/docs/screenshots/projects.png and b/docs/screenshots/projects.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 1e6140b..e167a90 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/tasks.png b/docs/screenshots/tasks.png index c6f8b4d..dfecdf1 100644 Binary files a/docs/screenshots/tasks.png and b/docs/screenshots/tasks.png differ diff --git a/docs/screenshots/usage.png b/docs/screenshots/usage.png index 9c95cca..0fb5bb4 100644 Binary files a/docs/screenshots/usage.png and b/docs/screenshots/usage.png differ diff --git a/docs/superpowers/plans/2026-08-09-command-center-review-remediation.md b/docs/superpowers/plans/2026-08-09-command-center-review-remediation.md new file mode 100644 index 0000000..0c106ea --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-command-center-review-remediation.md @@ -0,0 +1,197 @@ +# Command Center Review Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve every merge-blocking command-center review finding while preserving terminal, IPC, localization, and responsive-layout behavior. + +**Architecture:** Keep Cockpit as an internal renderer route, but make the shared shell the only user-facing navigation model. Reconcile sidebar entities by stable keys, centralize active-context synchronization, and make startup restoration a one-shot coordinator that cannot override later user intent. Move secondary project controls into an accessible display menu without changing their existing handlers. + +**Tech Stack:** Electron 43, TypeScript 5.5, vanilla DOM, Vitest 3, Playwright 1.62, axe-core. + +## Global Constraints + +- Do not introduce React, Tailwind, a new UI framework, or a new IPC contract. +- Preserve all existing provider-aware Open, project selection, Cockpit terminal lifecycle, and persisted-session behavior. +- Preserve Korean, English, Japanese, and Chinese localization. +- Preserve WCAG 2.1 AA, keyboard parity, focus restoration, terminal geometry, and narrow-layout containment. +- Every behavior change follows a red-green test cycle and each task ends in a focused commit. + +--- + +### Task 1: Stable, synchronized, accessible shell entities + +**Files:** +- Modify: `src/renderer/shell.ts` +- Modify: `src/renderer/shell/shell.css` +- Modify: `src/shared/shellNavigation.ts` +- Modify: `src/shared/shellNavigation.test.ts` +- Modify: `src/renderer/locales/en.json` +- Modify: `src/renderer/locales/ko.json` +- Modify: `src/renderer/locales/ja.json` +- Modify: `src/renderer/locales/zh.json` +- Modify: `src/shared/i18n.test.ts` +- Modify: `qa/screenshot.mjs` + +**Interfaces:** +- `ShellController` adds `setActiveProject(path: string | null): void` and `setActiveSession(id: string | null): void`. +- `shellEntityKey(kind, id)` returns stable `project:` or `session:` keys. +- `sessionAccessibleLabel(item, localizedStatus)` includes label, detail, and explicit status. + +- [ ] **Step 1: Write failing model and localization tests** + +Add tests proving entity keys remain stable, session accessible labels include localized status, and required status keys exist in all four locales. + +- [ ] **Step 2: Run focused tests and verify the new expectations fail** + +Run: `npm test -- src/shared/shellNavigation.test.ts src/shared/i18n.test.ts` + +Expected: FAIL because the helpers/status keys do not exist. + +- [ ] **Step 3: Implement keyed reconciliation and context synchronization** + +Maintain keyed caches for session and project buttons. Update existing nodes in place, move them into the required grouped order, and remove only missing keys. Preserve focus when an unchanged key is refreshed. Make group labels semantic headings, associate sections through `aria-labelledby`, and give every session button an accessible name containing its localized activity label. Programmatic active setters must update `.selected` and `aria-current` on all current and subsequently reconciled rows. + +Quick Open must flatten filtered sessions through `buildSessionGroups`, activate the first urgency-ordered result, clear the query after activation, and synchronize the active entity. + +- [ ] **Step 4: Add real shell refresh QA** + +Focus an existing sidebar project, invoke the real refresh path, and assert that the same DOM node remains focused. Verify session sections expose headings and session rows include status text when sessions exist. + +- [ ] **Step 5: Run focused tests, build, and screenshot QA** + +Run: `npm test -- src/shared/shellNavigation.test.ts src/shared/i18n.test.ts` + +Run: `npm run build && npm run qa` + +Expected: PASS with stable sidebar node/focus assertions and zero console/page errors. + +- [ ] **Step 6: Commit** + +```powershell +git add src/renderer/shell.ts src/renderer/shell/shell.css src/shared/shellNavigation.ts src/shared/shellNavigation.test.ts src/renderer/locales src/shared/i18n.test.ts qa/screenshot.mjs +git commit -m "fix: stabilize shared shell navigation" +``` + +### Task 2: Deterministic context restoration and internal Cockpit routing + +**Files:** +- Create: `src/shared/contextRestore.ts` +- Create: `src/shared/contextRestore.test.ts` +- Modify: `src/renderer/main.ts` +- Modify: `src/renderer/nav.ts` +- Modify: `src/renderer/index.html` +- Modify: `src/renderer/cockpitView.ts` +- Modify: `qa/screenshot.mjs` +- Modify: `qa/audit.mjs` + +**Interfaces:** +- `createContextRestoreCoordinator(saved, cockpitAvailable)` exposes `projectsLoaded(paths)`, `sessionsLoaded(ids)`, `cancel()`, and returns one restore decision at most once. +- `mountNav` retains `show('cockpit')` for internal activation while no `.rail-item[data-view="cockpit"]` is user-facing. +- Notification activation emits the existing Cockpit navigation callback rather than clicking a removed rail button. + +- [ ] **Step 1: Write failing coordinator tests** + +Cover a valid project restore, a missing project fallback after the project source loads, a valid session restore after the session source loads, a missing/unavailable session fallback, and cancellation before delayed data arrives. Assert no coordinator returns more than one decision. + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `npm test -- src/shared/contextRestore.test.ts` + +Expected: FAIL because the coordinator does not exist. + +- [ ] **Step 3: Implement one-shot restore and active shell synchronization** + +Apply restoration only after the requested entity's relevant source has completed its initial load. Persist the Projects fallback, clear the pending request after the first decision, and cancel it on user navigation. Every project/session restore or programmatic open calls the Task 1 active setter. A view activation clears entity selection unless it is the internal Cockpit route selected through a session. + +- [ ] **Step 4: Remove the standalone Cockpit destination** + +Delete the Cockpit rail button but keep `#view-cockpit` and the internal `ViewId`. Replace notification button-click routing with an exported renderer callback that shows Cockpit, selects the existing session, persists the session context, and marks the matching shared-shell row active. Update QA selectors to enter Cockpit through a real session route when available and skip the Windows-only view otherwise. + +- [ ] **Step 5: Run focused tests, full build, and Electron audits** + +Run: `npm test -- src/shared/contextRestore.test.ts src/shared/shellNavigation.test.ts src/shared/cockpitModel.test.ts` + +Run: `npm run build && npm run qa && npm run qa:audit` + +Expected: PASS; no standalone Cockpit navigation button; restored/programmatic entities have selected state; no terminal remount or geometry regression. + +- [ ] **Step 6: Commit** + +```powershell +git add src/shared/contextRestore.ts src/shared/contextRestore.test.ts src/renderer/main.ts src/renderer/nav.ts src/renderer/index.html src/renderer/cockpitView.ts qa/screenshot.mjs qa/audit.mjs +git commit -m "fix: make shell context restoration deterministic" +``` + +### Task 3: Simplified project controls and remaining icon migration + +**Files:** +- Modify: `src/renderer/index.html` +- Modify: `src/renderer/projectsView.ts` +- Modify: `src/renderer/main.ts` +- Modify: `src/renderer/shutdown.ts` +- Modify: `src/renderer/icons.ts` +- Modify: `src/renderer/design/components.css` +- Modify: `src/renderer/features/projects/projects.css` +- Modify: `src/renderer/locales/en.json` +- Modify: `src/renderer/locales/ko.json` +- Modify: `src/renderer/locales/ja.json` +- Modify: `src/renderer/locales/zh.json` +- Modify: `src/shared/i18n.test.ts` +- Modify: `qa/screenshot.mjs` +- Modify: `qa/audit.mjs` + +**Interfaces:** +- The visible project toolbar contains New Project, search, sort, aggregate status, and an accessible Display menu. +- The Display menu owns hidden-project, list/card mode, and provider controls using the existing handlers. +- `#open-selected` is hidden when selection is empty and visible only when at least one project is selected. + +- [ ] **Step 1: Write failing presentation/localization tests** + +Add tests for selection-action visibility and all new Display menu labels. Extend audit assertions so the Display trigger has `aria-haspopup="menu"`, correct expanded state, keyboard Escape closure, and labeled menu items. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `npm test -- src/renderer/projectOverview.test.ts src/shared/i18n.test.ts` + +Expected: FAIL because the visibility helper/labels do not exist. + +- [ ] **Step 3: Move secondary controls into the Display menu** + +Preserve element IDs and existing event handlers. Implement click, Enter/Space, outside-click, and Escape behavior with focus return. Hide Open Selected while the selection set is empty and reveal it when selection begins. Keep controls contained at 520px in all four languages. + +- [ ] **Step 4: Replace remaining structural emoji icons** + +Use the local SVG factory for title-bar refresh/theme and shutdown menu controls. Keep decorative provider/content identity unchanged and preserve localized accessible labels and tooltips. + +- [ ] **Step 5: Run final local gates** + +Run: `npm run build` + +Run: `npm test` + +Run: `npm run qa` + +Run: `npm run qa:audit` + +Run: `git diff --check` + +Expected: PASS with zero console/page errors, zero serious/critical accessibility violations, and no horizontal overflow. + +- [ ] **Step 6: Commit** + +```powershell +git add src/renderer/index.html src/renderer/projectsView.ts src/renderer/main.ts src/renderer/shutdown.ts src/renderer/icons.ts src/renderer/design/components.css src/renderer/features/projects/projects.css src/renderer/locales src/shared/i18n.test.ts qa +git commit -m "fix: simplify command center controls" +``` + +## Final acceptance checklist + +- [ ] Unchanged sidebar project/session nodes retain identity and focus across refresh. +- [ ] Programmatic and restored project/session contexts synchronize selected styling and `aria-current`. +- [ ] Startup restoration completes once and cannot override subsequent user navigation. +- [ ] Cockpit is reachable through live-session/project workflows but has no standalone global navigation item. +- [ ] Session status is announced as localized text and session groups use semantic headings. +- [ ] Project secondary controls live in one accessible Display menu; Open Selected is contextual. +- [ ] Quick Open preserves urgency order, clears after activation, and updates selected state. +- [ ] Structural navigation/title/shutdown icons use the local SVG set. +- [ ] Build, complete Vitest suite, screenshot QA, axe audit, GitHub CI, and `git diff --check` pass. diff --git a/docs/superpowers/plans/2026-08-09-hybrid-command-center-implementation.md b/docs/superpowers/plans/2026-08-09-hybrid-command-center-implementation.md new file mode 100644 index 0000000..133ca31 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-hybrid-command-center-implementation.md @@ -0,0 +1,582 @@ +# Hybrid Command Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild DevDeck's renderer as a clean shared command-center shell that unifies project scanning and live-session response while preserving all existing behavior and QA guarantees. + +**Architecture:** Keep Electron, the existing preload/IPC contracts, and vanilla TypeScript DOM rendering. Introduce pure presentation models plus focused shell/component modules, move Cockpit session navigation into the shared sidebar, and migrate styles into ordered design/shell/feature files copied by the existing build pipeline. + +**Tech Stack:** Electron 43, TypeScript 5.5, esbuild, Vitest 3, Playwright 1.62, axe-core, xterm 6, CSS custom properties. + +## Global Constraints + +- Do not introduce React, Tailwind, or another UI framework. +- Preserve all existing main-process and preload IPC contracts. +- Preserve Windows embedded-terminal lifecycle, geometry, restoration, search, clipboard, image-path, fork, restart, and persistence behavior. +- Preserve Korean, English, Japanese, and Chinese localization. +- Preserve WCAG 2.1 AA audit coverage and keyboard parity. +- Dark mode is the only mandatory finished theme in this implementation. +- Keep the application buildable and testable after every task. +- Keep unchanged project and Cockpit DOM nodes stable during refresh where current reconciliation already guarantees it. + +--- + +## File map + +### New files + +- `src/renderer/design/tokens.css` — colors, typography, spacing, radii, focus, and motion tokens. +- `src/renderer/design/base.css` — reset, body, typography, generic hidden/focus/disabled states. +- `src/renderer/design/components.css` — buttons, inputs, menus, status indicators, rows, toolbars, dialogs, and drawers. +- `src/renderer/shell/shell.css` — title bar, expanded/collapsed sidebar, main pane, and responsive shell layout. +- `src/renderer/features/projects/projects.css` — project overview list/card presentation. +- `src/renderer/features/cockpit/cockpit.css` — global session list and terminal workspace presentation. +- `src/renderer/features/secondary.css` — Tasks, Usage, Settings, modal, and footer alignment. +- `src/renderer/icons.ts` — local SVG icon markup and DOM creation. +- `src/renderer/icons.test.ts` — icon allowlist and accessibility behavior. +- `src/shared/shellNavigation.ts` — pure shell navigation types, grouping, counts, and context restoration. +- `src/shared/shellNavigation.test.ts` — navigation-model tests. +- `src/renderer/shell.ts` — shell mounting, collapse persistence, project rows, and view activation hooks. +- `src/renderer/projectOverview.ts` — pure project-row presentation derivation. +- `src/renderer/projectOverview.test.ts` — project hierarchy and metadata tests. +- `src/renderer/projectMemorySurface.ts` — responsive drawer/sheet mode selection. +- `src/renderer/projectMemorySurface.test.ts` — surface-mode tests. + +### Modified files + +- `src/renderer/index.html` — semantic shared sidebar and simplified Cockpit container. +- `src/renderer/main.ts` — mount icons/shell and connect project/Cockpit updates. +- `src/renderer/nav.ts` — explicit view activation API and shared-sidebar support. +- `src/renderer/projectsView.ts` — row-first default, simplified toolbar, shell project publishing. +- `src/renderer/cockpitView.ts` — publish session models, global sidebar selection, simplified workspace header. +- `src/renderer/projectMemoryModal.ts` — drawer/sheet classes and semantics. +- `src/renderer/nextView.ts` — shared toolbar/row classes and simplified control grouping. +- `src/renderer/usageView.ts` — shared heading/tab/table classes. +- `src/renderer/settingsView.ts` — shared section/control-row classes. +- `src/renderer/styles.css` — temporary compatibility imports only, then removal of migrated declarations. +- `package.json` and `package-lock.json` — pinned `@fontsource-variable/geist@5.3.0` font asset dependency. +- `src/renderer/locales/{ko,en,ja,zh}.json` — shell and accessible icon labels. +- `scripts/copy-assets.mjs` — copy the ordered CSS tree and bundled font. +- `qa/screenshot.mjs` — shared-shell, sidebar, drawer, responsive, and localization scenes. +- `qa/audit.mjs` — shared-sidebar and drawer accessibility assertions. +- `package.json` — test count copy only if README/test badge updates are performed at release time. + +--- + +### Task 1: Design tokens and local SVG icons + +**Files:** +- Create: `src/renderer/design/tokens.css` +- Create: `src/renderer/design/base.css` +- Create: `src/renderer/design/components.css` +- Create: `src/renderer/icons.ts` +- Create: `src/renderer/icons.test.ts` +- Modify: `src/renderer/styles.css:1-52` +- Modify: `scripts/copy-assets.mjs:10-18` +- Modify: `package.json` +- Modify: `package-lock.json` + +**Interfaces:** +- Produces: `type IconName`, `iconMarkup(name: IconName): string`, and `createIcon(name: IconName, className?: string): SVGSVGElement`. +- Produces: stable CSS tokens prefixed with `--dd-` and shared classes `.ui-button`, `.ui-icon-button`, `.ui-toolbar`, `.ui-status`, `.ui-row`, `.ui-drawer`. + +- [ ] **Step 1: Write the failing icon allowlist test** + +```ts +import { describe, expect, it } from 'vitest'; +import { iconMarkup, type IconName } from './icons'; + +describe('iconMarkup', () => { + it('returns local, currentColor SVG markup for every shell icon', () => { + const names: IconName[] = ['search', 'projects', 'sessions', 'tasks', 'usage', 'settings', 'refresh', 'more', 'play', 'panel-left']; + for (const name of names) { + expect(iconMarkup(name)).toContain(' { + it('orders urgent groups before pins and counts only genuine attention', () => { + expect(buildSessionGroups(rows).map((g) => g.kind)).toEqual(['attention', 'working', 'pinned']); + expect(attentionCount(rows)).toBe(1); + }); + + it('restores a valid context and falls back to projects for missing state', () => { + expect(restoreShellContext({ kind: 'session', id: 'ask' }, new Set(['C:/ask']), new Set(['ask']))).toEqual({ kind: 'session', id: 'ask' }); + expect(restoreShellContext({ kind: 'session', id: 'gone' }, new Set(['C:/ask']), new Set(['ask']))).toEqual({ kind: 'view', id: 'projects' }); + }); + + it('quick-open matches visible labels, details, and project names case-insensitively', () => { + const result = filterShellItems('CHECKOUT', rows, [{ path: 'C:/checkout', name: 'checkout-api', branch: 'main' }]); + expect(result.projects.map((x) => x.path)).toEqual(['C:/checkout']); + expect(result.sessions).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: `npm test -- src/shared/shellNavigation.test.ts` + +Expected: FAIL because the module does not exist. + +- [ ] **Step 3: Implement the pure model** + +Define `ShellProjectInput` as `{ path: string; name: string; branch: string | null }`. Define group kinds as `'attention' | 'working' | 'pinned' | 'turn' | 'quiet' | 'previous'`. Sort within groups by case-insensitive label, exclude pinned attention/working rows from the pinned group, and return only non-empty groups. Define contexts as `{ kind: 'view'; id: 'projects' | 'next' | 'usage' | 'settings' } | { kind: 'project'; path: string } | { kind: 'session'; id: string }`. Quick Open trims and lowercases its query, matches session label/detail and project name/branch, and preserves urgency/project ordering. + +- [ ] **Step 4: Run focused and shared model tests** + +Run: `npm test -- src/shared/shellNavigation.test.ts src/shared/cockpitModel.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit the navigation model** + +```powershell +git add src/shared/shellNavigation.ts src/shared/shellNavigation.test.ts +git commit -m "feat: model shared command center navigation" +``` + +### Task 3: Shared application shell and explicit view activation + +**Files:** +- Create: `src/renderer/shell.ts` +- Create: `src/renderer/shell/shell.css` +- Modify: `src/renderer/index.html:12-77` +- Modify: `src/renderer/nav.ts:1-15` +- Modify: `src/renderer/main.ts:1-190` +- Modify: `src/renderer/locales/{ko,en,ja,zh}.json` + +**Interfaces:** +- Consumes: `createIcon()` from Task 1. +- Produces: `mountShell(options): ShellController`. +- `ShellController` exposes `showView(view)`, `setCockpitAvailable(on)`, `setSessionGroups(groups)`, `setProjects(items)`, `setCollapsed(on)`, and `activeView()`. +- `mountNav` returns `show(view: ViewId): void` rather than closing over an inaccessible function. + +- [ ] **Step 1: Add a failing pure test for collapse normalization** + +Add to `src/shared/shellNavigation.test.ts`: + +```ts +import { normalizeSidebarState } from './shellNavigation'; + +it('accepts only persisted boolean sidebar state', () => { + expect(normalizeSidebarState(true)).toBe(true); + expect(normalizeSidebarState('true')).toBe(false); + expect(normalizeSidebarState(undefined)).toBe(false); +}); +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `npm test -- src/shared/shellNavigation.test.ts` + +Expected: FAIL because `normalizeSidebarState` is missing. + +- [ ] **Step 3: Implement the shell markup and controller** + +Replace `#rail` with semantic `#app-sidebar` while retaining `.rail-item[data-view]` on view buttons for compatibility. Use visible localized labels in expanded mode and SVG icons from `createIcon`. Place Quick Open, Projects, Tasks, Usage at the top; session groups and compact project rows in scrollable sections; Settings and Language at the bottom. Move `#ck-list` into the shared sidebar on Windows and leave `#view-cockpit` with only the terminal workspace. + +Implement Quick Open as a real text input. Typing filters only the sidebar session/project rows; Enter activates the first visible result and Escape clears the query. Persist the last active `ShellContext` as JSON in renderer-local `localStorage` under `devdeck:shell-context:v1`, validate it through `restoreShellContext` on launch, and never persist terminal output or project data in that key. + +Implement `mountNav` as: + +```ts +export type ViewId = 'projects' | 'usage' | 'settings' | 'next' | 'cockpit'; +export function mountNav(onShow: (view: ViewId) => void): { show(view: ViewId): void; active(): ViewId }; +``` + +Do not auto-switch on new attention. Persist collapse through the existing settings API only after adding a dedicated shell field; until that IPC change is deliberately implemented, reuse `cockpitSidebarCollapsed` for the unified sidebar. + +- [ ] **Step 4: Run build and existing navigation QA smoke** + +Run: `npm run build` + +Expected: PASS with no TypeScript errors. + +Run: `npm run qa` + +Expected: existing scenes complete; screenshot differences are expected, but structural assertions and console/page error checks pass. + +- [ ] **Step 5: Commit the shell** + +```powershell +git add src/renderer/index.html src/renderer/shell.ts src/renderer/shell src/renderer/nav.ts src/renderer/main.ts src/renderer/locales +git commit -m "feat: introduce shared command center shell" +``` + +### Task 4: Row-first project overview and sidebar projects + +**Files:** +- Create: `src/renderer/projectOverview.ts` +- Create: `src/renderer/projectOverview.test.ts` +- Create: `src/renderer/features/projects/projects.css` +- Modify: `src/renderer/projectsView.ts:17-755` +- Modify: `src/renderer/index.html:39-62` +- Modify: `src/renderer/main.ts` + +**Interfaces:** +- Consumes: `ProjectViewModel` and per-project live activity. +- Produces: `projectRowModel(project, live, cost): ProjectRowModel` with `headline`, `branchLine`, `cue`, `state`, `secondary`, and `primaryLabelKey`. +- `projectsView` publishes `onProjectsChanged(listener)`, `currentProjects()`, and `focusProject(path)` without exposing mutable arrays. + +- [ ] **Step 1: Write failing project hierarchy tests** + +```ts +import { describe, expect, it } from 'vitest'; +import { projectRowModel } from './projectOverview'; +import type { ProjectViewModel } from '../shared/types'; + +it('keeps the resume cue primary and moves cost/providers to secondary metadata', () => { + const project = { name: 'checkout-api', branch: 'main', uncommitted: 3, ahead: 1, resumeCue: { kind: 'lastMessage', text: 'review migration output' }, sessionCount: 2 } as ProjectViewModel; + expect(projectRowModel(project, 'attention', 1.34)).toMatchObject({ + headline: 'checkout-api', cue: 'review migration output', state: 'attention', primaryLabelKey: 'common.open', + }); + expect(projectRowModel(project, 'attention', 1.34).secondary).toContain('~$1.34'); +}); +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: `npm test -- src/renderer/projectOverview.test.ts` + +Expected: FAIL because `projectOverview.ts` does not exist. + +- [ ] **Step 3: Implement row models and make list mode the default** + +Set `viewMode` default to `'list'`. Reduce the always-visible toolbar to New Project, search, sort, and aggregate status. Move hidden count, view mode, and secondary filters into a display menu. Render the same row model in the main overview and a more compact model in `#shell-projects`. A compact sidebar project click activates Projects, clears unrelated filtering, scrolls the corresponding row into view, selects it, and places focus on the row; it does not launch an agent until Open is invoked. Preserve `cardCache`, `reconcileChildren`, provider-aware Open, task badge, GitHub/editor/folder actions, and multi-select semantics. + +- [ ] **Step 4: Run focused tests, build, and project screenshot scenes** + +Run: `npm test -- src/renderer/projectOverview.test.ts src/renderer/projectMemoryPresentation.test.ts` + +Expected: PASS. + +Run: `npm run build` + +Expected: PASS. + +Run: `npm run qa` + +Expected: project refresh node reuse, narrow overflow, provider menu containment, and console/page error checks pass. + +- [ ] **Step 5: Commit the project overview** + +```powershell +git add src/renderer/projectOverview.ts src/renderer/projectOverview.test.ts src/renderer/features/projects src/renderer/projectsView.ts src/renderer/index.html src/renderer/main.ts +git commit -m "feat: redesign project overview as status board" +``` + +### Task 5: Integrate Cockpit sessions into the shared sidebar + +**Files:** +- Create: `src/renderer/features/cockpit/cockpit.css` +- Modify: `src/renderer/cockpitView.ts:21-930` +- Modify: `src/renderer/index.html` +- Modify: `src/renderer/main.ts` +- Modify: `src/shared/cockpitModel.ts` +- Modify: `src/shared/cockpitModel.test.ts` + +**Interfaces:** +- Consumes: `ShellSessionInput` and `buildSessionGroups` from Task 2. +- Produces: `cockpitNavigationItems(): ShellSessionInput[]` and `onCockpitNavigationChange(listener): () => void`. +- Produces: `sessionNavigationItem(session, label, detail, pinned): ShellSessionInput` as the pure adapter used by `cockpitNavigationItems()`. +- Session-row activation invokes the shell's explicit `showView('cockpit')` before selecting the terminal. + +- [ ] **Step 1: Write a failing urgency/deduplication test** + +Add to `src/shared/cockpitModel.test.ts`: + +```ts +it('adapts a cockpit session without losing project ownership or activity', () => { + const session = { id: 'a', projectPath: 'C:/a', name: 'repo', agentId: 'codex', status: 'running', staleLevel: 'fresh', branch: 'main', dirty: 0, activity: 'attention' } as CockpitSession; + expect(sessionNavigationItem(session, 'review api', 'main · Codex', true)).toEqual({ + id: 'a', projectPath: 'C:/a', label: 'review api', detail: 'main · Codex', activity: 'attention', pinned: true, + }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `npm test -- src/shared/cockpitModel.test.ts` + +Expected: FAIL because `sessionNavigationItem` is missing. + +- [ ] **Step 3: Publish session navigation and simplify the workspace header** + +Adapt the existing `renderList` grouping through the shared navigation model without changing `Live`, PTY, persistence, activity polling, notification, or metadata behavior. Replace emoji activity marks, brain, clock, folder, pin, rename, and close glyphs with local SVG icons plus localized text/labels. Keep context percentage text visible. Session click must activate Cockpit and then select the existing terminal node; it must never respawn or remount it. + +Remove the nested Cockpit sidebar width from `.ck-wrap`; the terminal main pane should fill the shared content area. Retain a one-click global sidebar collapse control for maximum terminal width. + +- [ ] **Step 4: Run Cockpit tests and QA geometry checks** + +Run: `npm test -- src/shared/cockpitModel.test.ts src/renderer/agentSelection.test.ts` + +Expected: PASS. + +Run: `npm run build` + +Expected: PASS. + +Run: `npm run qa` + +Expected: Cockpit main/content height ratio remains at least 0.8, usage footer stays 26px, long names remain contained, and zero-attention badge remains hidden. + +- [ ] **Step 5: Commit session integration** + +```powershell +git add src/renderer/features/cockpit src/renderer/cockpitView.ts src/renderer/index.html src/renderer/main.ts src/shared/cockpitModel.ts src/shared/cockpitModel.test.ts +git commit -m "feat: integrate live sessions into command center" +``` + +### Task 6: Convert Project Memory to drawer and narrow sheet + +**Files:** +- Create: `src/renderer/projectMemorySurface.ts` +- Create: `src/renderer/projectMemorySurface.test.ts` +- Modify: `src/renderer/projectMemoryModal.ts` +- Modify: `src/renderer/design/components.css` +- Modify: `qa/screenshot.mjs` +- Modify: `qa/audit.mjs` + +**Interfaces:** +- Produces: `memorySurfaceMode(width: number): 'drawer' | 'sheet'` with drawer at widths `>= 720` and sheet below 720. +- `openProjectMemoryModal` keeps its existing public signature to avoid changing callers. + +- [ ] **Step 1: Write the failing mode test** + +```ts +import { describe, expect, it } from 'vitest'; +import { memorySurfaceMode } from './projectMemorySurface'; + +describe('memorySurfaceMode', () => { + it('uses an overlay drawer on wide layouts and a sheet on narrow layouts', () => { + expect(memorySurfaceMode(1000)).toBe('drawer'); + expect(memorySurfaceMode(719)).toBe('sheet'); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: `npm test -- src/renderer/projectMemorySurface.test.ts` + +Expected: FAIL because the module does not exist. + +- [ ] **Step 3: Implement responsive surface semantics** + +Keep `role="dialog"`, `aria-modal="true"`, labelled title, Escape handling, focus trap, outside click, loading, retry, and trigger focus restoration. Add mode classes computed at open and updated on resize. A drawer overlays from the right and must not alter `#content`, `.ck-main`, or terminal bounds. The sheet fills the viewport width below 720px without horizontal overflow. + +- [ ] **Step 4: Extend and run memory QA** + +Add screenshot assertions for right-edge alignment at 1000px, full-width containment at 520px, unchanged terminal/content geometry while open, Escape close, and focus return. + +Run: `npm test -- src/renderer/projectMemorySurface.test.ts src/renderer/projectMemoryPresentation.test.ts` + +Expected: PASS. + +Run: `npm run qa && npm run qa:audit` + +Expected: Project Memory scenes and axe checks pass with no overflow. + +- [ ] **Step 5: Commit the drawer** + +```powershell +git add src/renderer/projectMemorySurface.ts src/renderer/projectMemorySurface.test.ts src/renderer/projectMemoryModal.ts src/renderer/design/components.css qa/screenshot.mjs qa/audit.mjs +git commit -m "feat: present project memory as responsive drawer" +``` + +### Task 7: Align Tasks, Usage, Settings, dialogs, and footer + +**Files:** +- Create: `src/renderer/features/secondary.css` +- Modify: `src/renderer/nextView.ts` +- Modify: `src/renderer/usageView.ts` +- Modify: `src/renderer/settingsView.ts` +- Modify: `src/renderer/usageModal.ts` +- Modify: `src/renderer/usageBar.ts` +- Modify: `src/renderer/styles.css` +- Modify: `src/renderer/locales/{ko,en,ja,zh}.json` + +**Interfaces:** +- Consumes: shared `.ui-toolbar`, `.ui-button`, `.ui-row`, `.ui-dialog`, and typography tokens. +- Produces no new data or IPC contracts. + +- [ ] **Step 1: Add a failing localization completeness assertion** + +Append the exact shell keys to the existing `REQUIRED` array in `src/shared/i18n.test.ts`: + +```ts +'shell.quick_open', 'shell.needs_you', 'shell.working', +'shell.projects', 'shell.collapse', 'shell.expand', +``` + +- [ ] **Step 2: Run the test and verify missing keys fail** + +Run: `npm test -- src/shared/i18n.test.ts` + +Expected: FAIL listing the new shell keys. + +- [ ] **Step 3: Apply shared visual primitives without changing feature behavior** + +Tasks keeps add, due date, filters, completion, calendar, and provider-aware Open. Usage keeps three cost cards, provider filters, tables, charts, and local-only copy. Settings keeps every current field and callback. Dialogs retain fixed overlays, focus traps, Escape behavior, and geometry stability. Remove migrated selectors from `styles.css` only after their replacement selectors are active. + +- [ ] **Step 4: Run localization, build, screenshot, and axe checks** + +Run: `npm test -- src/shared/i18n.test.ts` + +Expected: PASS. + +Run: `npm run build && npm run qa && npm run qa:audit` + +Expected: PASS; all four languages render, provider controls remain contained, Usage has three headline cards, and the footer remains exactly 26px. + +- [ ] **Step 5: Commit secondary surfaces** + +```powershell +git add src/renderer/features/secondary.css src/renderer/nextView.ts src/renderer/usageView.ts src/renderer/settingsView.ts src/renderer/usageModal.ts src/renderer/usageBar.ts src/renderer/styles.css src/renderer/locales +git commit -m "feat: align secondary views with command center" +``` + +### Task 8: Full shell QA, compatibility cleanup, and documentation + +**Files:** +- Modify: `qa/screenshot.mjs` +- Modify: `qa/audit.mjs` +- Modify: `README.md` +- Modify: `docs/screenshots/*.png` through the existing deterministic capture workflow +- Modify: `src/renderer/styles.css` + +**Interfaces:** +- Verifies every interface from Tasks 1-7; produces no runtime API. + +- [ ] **Step 1: Add final shared-shell assertions before cleanup** + +In `qa/screenshot.mjs`, assert: + +```js +const shellGeometry = await win.evaluate(() => { + const shell = document.getElementById('shell')?.getBoundingClientRect(); + const sidebar = document.getElementById('app-sidebar')?.getBoundingClientRect(); + const content = document.getElementById('content')?.getBoundingClientRect(); + return { + present: !!shell && !!sidebar && !!content, + contained: !!shell && !!sidebar && !!content && sidebar.left >= shell.left && content.right <= shell.right, + overlap: !!sidebar && !!content && sidebar.right > content.left + 1, + }; +}); +if (!shellGeometry.present || !shellGeometry.contained || shellGeometry.overlap) { + console.error('QA FAILED — shared shell geometry is invalid:', JSON.stringify(shellGeometry)); + await closeApp(); process.exit(1); +} +``` + +Also capture expanded/collapsed sidebar, Needs You selection, project overview list/card, Cockpit workspace, memory drawer/sheet, all secondary views, and all four languages. + +- [ ] **Step 2: Run the complete unit suite before deleting compatibility CSS** + +Run: `npm test` + +Expected: all tests pass. + +- [ ] **Step 3: Remove superseded CSS and stale emoji-specific presentation** + +Delete only declarations proven unused by `rg` against renderer HTML/TypeScript. Keep compatibility class names required by QA or runtime selectors until their assertions and callers are updated in the same change. Replace README screenshots and product description to show the shared command center without claiming unsupported light mode or non-Windows embedded terminals. + +- [ ] **Step 4: Run final build and QA gates** + +Run: `npm run build` + +Expected: PASS. + +Run: `npm test` + +Expected: PASS. + +Run: `npm run qa` + +Expected: PASS with zero console errors and zero page errors. + +Run: `npm run qa:audit` + +Expected: PASS with zero WCAG A/AA violations on Projects, Cockpit, Tasks, Usage, Settings, Project Memory, and the usage dialog. + +Run: `git diff --check` + +Expected: no whitespace errors. + +- [ ] **Step 5: Commit final QA and documentation** + +```powershell +git add qa README.md docs/screenshots src/renderer/styles.css +git commit -m "test: verify command center redesign" +``` + +--- + +## Final acceptance checklist + +- [ ] Shared expanded/collapsed sidebar works with pointer and keyboard. +- [ ] Needs You and Working sessions are visible from every primary context. +- [ ] Selecting a live session activates its existing Cockpit terminal without remounting it. +- [ ] Projects default to the row-first status board and retain optional cards. +- [ ] Provider-aware Open behavior is unchanged. +- [ ] Project Memory is an overlay drawer at 1000px and contained sheet at 520px. +- [ ] Tasks, Usage, Settings, dialogs, and footer use the shared design system. +- [ ] All four languages fit without unintended horizontal overflow. +- [ ] Terminal fill ratio, usage footer height, node reuse, menu containment, and focus restoration assertions pass. +- [ ] Build, complete Vitest suite, Playwright screenshot QA, axe audit, and `git diff --check` pass. diff --git a/docs/superpowers/specs/2026-08-09-hybrid-command-center-design.md b/docs/superpowers/specs/2026-08-09-hybrid-command-center-design.md new file mode 100644 index 0000000..9e8ddbf --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-hybrid-command-center-design.md @@ -0,0 +1,305 @@ +# DevDeck Hybrid Command Center Redesign + +**Date:** 2026-08-09 + +**Status:** Approved design + +**Reference:** Orca (`stablyai/orca`) visual hierarchy and workspace navigation patterns + +## 1. Objective + +Redesign DevDeck as a cleaner hybrid command center that keeps its project-at-a-glance strength while making live AI sessions faster to monitor and resume. + +This is not a visual reskin. The redesign must improve information hierarchy, simplify navigation and primary actions, reduce duplicate Projects/Cockpit flows, and make the renderer easier to maintain. Existing product capabilities, local-first behavior, internationalization, accessibility, and terminal reliability remain intact. + +## 2. Product priority + +The default experience remains project-oriented: users open DevDeck to understand the state of many repositories and choose what to work on. Live sessions that need attention are elevated above ordinary project activity so users can respond without entering a separate monitoring mode. + +The product therefore combines two jobs in one shell: + +1. Scan project state and decide what matters. +2. Enter or resume the relevant AI session with minimal context switching. + +## 3. Design principles + +- **Status before decoration:** color communicates actionable state, not visual personality. +- **One primary action per context:** secondary actions move to hover, overflow, or contextual menus. +- **Progressive disclosure:** show the minimum needed to choose an item; reveal detail after selection. +- **Stable spatial model:** global navigation stays in one sidebar and selected work stays in one main pane. +- **Project and session continuity:** a live session belongs to a project context rather than a disconnected Cockpit destination. +- **Keyboard parity:** every pointer workflow has an equivalent keyboard workflow. +- **Incremental migration:** preserve the Electron and TypeScript architecture; avoid a framework rewrite. + +## 4. Application shell + +### 4.1 Title bar + +Keep the frameless native title bar and window controls. Reduce decorative branding and reserve the central drag area. Global refresh and shutdown controls remain available but use local SVG icons and consistent tooltips instead of emoji glyphs. + +### 4.2 Sidebar + +Replace the 48px icon rail with a resizable or fixed-width sidebar around 220px. It may collapse to an icon-only state when terminal width is more valuable. + +The expanded sidebar is ordered as follows: + +1. Quick Open +2. Tasks +3. Usage +4. Needs You sessions +5. Working sessions +6. Projects +7. Settings and language controls + +Needs You and Working groups show counts and compact rows. Status is expressed by a small labeled indicator, never by color alone. Quiet and previous sessions live under their project or in an expandable secondary group so they do not compete with active work. + +### 4.3 Main pane + +The main pane renders one of four contexts: + +- Project overview +- Selected project/session workspace +- Task board +- Usage or settings surface + +The shell itself does not remount when contexts change. This preserves selection, focus, scroll position, and terminal geometry. + +### 4.4 Startup behavior + +On launch, restore the last valid context. If it no longer exists, open the project overview. New Needs You sessions appear at the top of the sidebar but do not unexpectedly replace the user's restored screen. + +## 5. Project overview + +### 5.1 Default presentation + +Replace the project card grid as the default with a quiet row-based status board. Retain the card view as an optional display mode for users who prefer it. + +Each row contains: + +- Live-state indicator and project name +- Branch and working-tree summary +- Current resume cue or latest meaningful activity +- Session state or last activity +- One primary Open action + +Task count, unpushed state, providers, cost, timestamps, and secondary file/editor/GitHub actions remain available but are grouped into secondary metadata or the overflow menu. The row must stay readable at the supported narrow width without hiding the primary action. + +### 5.2 Toolbar + +The overview toolbar contains only: + +- View title and actionable aggregate counts +- Search +- Sort +- New project + +Live-state filtering moves into clickable sidebar groups. Hidden-project controls and view-mode controls move to a compact overflow or display menu. Multi-select and Open Selected appear only after selection begins. + +### 5.3 Project selection + +Selecting a row opens the project workspace in the main pane. Opening the primary action uses the current provider-aware resume rules. A row selection and a session selection both resolve to the same project context, avoiding separate navigation models. + +## 6. Project and session workspace + +### 6.1 Workspace header + +The header shows only information needed while working: + +- Project or session name +- Branch +- Provider and model +- Live state +- Context usage +- Contextual actions + +Cost, history, and less frequent project actions move to a details drawer or overflow menu. + +### 6.2 Terminal + +The terminal remains the dominant surface and retains its current lifecycle, split-independent geometry, search, clipboard, image-path handling, restart, restore, fork, and session persistence behavior. + +The redesign must not change terminal height when dialogs, drawers, provider limits, or sidebar groups open. Collapsing the sidebar increases terminal width without remounting the terminal. + +### 6.3 Needs You workflow + +The preferred loop is: + +1. Select a Needs You session in the sidebar. +2. Review the project context and terminal output. +3. Respond in the existing terminal. +4. When the agent resumes working, the item moves to Working. +5. Keyboard navigation selects the next Needs You item when requested by the user; it does not auto-switch without input. + +### 6.4 Session actions + +Resume, restore, start new, fork, rename, pin, and close use one consistent contextual action pattern. The primary action reflects the current state. Destructive actions keep explicit confirmation and focus restoration. + +## 7. Project Memory + +Replace the large central Project Memory modal with a right-side details drawer on wide layouts. The drawer contains the existing snapshot and timeline data and does not replace or resize the main context unexpectedly. + +At narrow widths, the drawer becomes a full-width modal sheet. It preserves dialog semantics, Escape handling, focus trapping, and focus return to its trigger. + +No Project Memory data contract or local-only behavior changes are required. + +## 8. Tasks, Usage, and Settings + +These remain dedicated main-pane contexts inside the shared shell. + +- **Tasks:** simplify the add/filter toolbar and use the same row, status, menu, and provider-open primitives as Projects. +- **Usage:** retain the three headline cost summaries and detailed filtering, but align typography, spacing, tabs, tables, and dialogs with the new tokens. +- **Settings:** divide settings into clearly labeled sections with consistent control rows; keep About and update state secondary. + +The calendar, usage modal, provider limits, localization, and existing data flows remain functionally unchanged. + +## 9. Visual system + +### 9.1 Typography + +Use a bundled Geist variable font for the interface and retain Cascadia Mono/Consolas fallbacks for terminal and code metadata. Establish a small, explicit scale for metadata, body, row labels, section titles, and view titles. + +### 9.2 Icons + +Replace navigation and action emoji with a curated local SVG icon set. Decorative emoji may remain only where it is part of provider or content identity. Every icon-only action has an accessible label and tooltip. + +### 9.3 Color and surfaces + +Use neutral surfaces with low-contrast structural borders: + +- App canvas +- Sidebar surface +- Raised or selected surface +- Popover/dialog surface + +Reserve semantic color for Needs You, Working, success, warning, destructive, provider identity, and focus. Selected rows primarily use surface contrast and shape rather than saturated color. + +### 9.4 Spacing and shape + +Define shared spacing, radius, border, shadow, focus-ring, motion, and density tokens. Repeated rows use restrained radii and separators instead of making every item a floating card. + +### 9.5 Themes + +Dark mode is the first complete theme because it matches the current product and terminal environment. Tokens must support a future light theme without changing component contracts. A light-theme toggle is not part of the first implementation unless all redesigned surfaces meet the same contrast and QA standards. + +## 10. Renderer architecture + +Keep the existing Electron main/preload/renderer boundary and vanilla TypeScript DOM approach. + +Split the renderer into the following layers: + +- `design/`: tokens, typography, icons, and base styles +- `shell/`: title bar, sidebar, context routing, and responsive behavior +- `components/`: reusable button, menu, row, status, toolbar, drawer, dialog, and empty/error primitives +- `features/projects/`: project overview and project-context presentation +- `features/sessions/`: session groups, session rows, and workspace header +- Existing feature modules for tasks, usage, settings, and terminal behavior + +DOM construction and event/state coordination should be separated. Components accept normalized view models and emit typed actions; feature modules continue to own IPC calls and product state. + +The migration must not introduce React, Tailwind, or a new UI framework. This keeps scope focused on the redesign and avoids rewriting tested renderer behavior. + +## 11. Data and state flow + +Existing IPC contracts remain authoritative. The renderer derives a unified navigation model from project data, live session state, persisted cockpit entries, tasks, and usage summaries. + +State updates follow this flow: + +1. Main process and existing scanners provide normalized data through current IPC APIs. +2. Feature modules convert data into presentation models. +3. The shell reconciles sidebar groups and active context without replacing unchanged DOM nodes. +4. Component actions call feature handlers. +5. Feature handlers invoke IPC and update only affected presentation models. + +Project rows and sidebar session rows must preserve node identity during refresh where practical, matching the current smooth-refresh guarantee. + +## 12. Error, loading, and empty states + +- Use skeletons only for initial content whose geometry is known. +- Replace indefinite skeletons with localized inline error states and retry actions. +- Preserve last-good provider usage and mark its age, matching current behavior. +- Use toast notifications for completed or failed user actions, not for persistent page-level errors. +- Empty states explain the next useful action and never resemble a filtered zero-result state. +- A failed secondary panel must not blank the terminal or project overview. + +## 13. Accessibility and localization + +- Preserve WCAG 2.1 AA audit coverage. +- All navigation, menus, drawers, dialogs, and row actions support keyboard operation. +- Focus remains visible and is restored after transient surfaces close. +- Status is represented by text and shape as well as color. +- Hover-revealed actions also appear on keyboard focus and touch-capable layouts. +- Korean, English, Japanese, and Chinese labels must fit without vertical single-character wrapping. +- Window controls retain native-purpose labels regardless of interface language. + +## 14. Verification strategy + +### 14.1 Automated tests + +- Retain the full Vitest suite. +- Add unit tests for navigation-model derivation, status grouping, active-context restoration, and contextual primary-action selection. +- Add component interaction tests for menus, drawer focus behavior, row keyboard behavior, and collapsed sidebar state. + +### 14.2 Playwright QA + +Extend the existing screenshot harness to cover: + +- Expanded and collapsed shell +- Project overview in list and optional card modes +- Needs You and Working groups +- Selected session workspace +- Project Memory drawer and narrow modal sheet +- Tasks, Usage, Settings, provider menus, and usage dialog +- All four languages +- 520px and 1000px layouts + +Existing geometry assertions remain mandatory, especially terminal fill ratio, 26px usage footer height, provider menu containment, card/row reconciliation, and narrow-layout overflow. + +### 14.3 Accessibility QA + +Run axe against every primary context and transient surface. Add manual keyboard checks for Quick Open, sidebar groups, session switching, menus, drawer open/close, and focus restoration. + +### 14.4 Completion criteria + +The redesign is complete when: + +- All existing unit tests pass. +- Build, screenshot QA, and accessibility audit pass without console or page errors. +- No supported viewport has unintended horizontal overflow. +- All four languages remain usable. +- Terminal geometry and session persistence behavior are unchanged. +- Projects and live sessions can be navigated through the shared shell without entering a disconnected Cockpit page. + +## 15. Delivery boundaries + +Included: + +- Shared design system and shell +- Project overview redesign +- Session navigation and workspace integration +- Project Memory drawer +- Visual alignment of Tasks, Usage, Settings, dialogs, and empty/error states +- Renderer decomposition necessary to support the redesign +- Automated and visual QA updates + +Excluded from this redesign: + +- Main-process scanner or IPC protocol rewrites +- New agent providers +- New worktree orchestration features +- React/Tailwind migration +- Mobile application +- Mandatory light theme + +## 16. Implementation sequence + +Implementation should proceed in verified vertical slices: + +1. Design tokens, typography, icons, and reusable primitives +2. Shared shell and navigation model +3. Project overview +4. Session workspace integration +5. Project Memory drawer +6. Tasks, Usage, and Settings alignment +7. Full regression, accessibility, localization, and visual QA + +Each slice must keep the application buildable and preserve unrelated user behavior. diff --git a/package-lock.json b/package-lock.json index 95a3602..ac6d70b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.30.0", "license": "MIT", "dependencies": { + "@fontsource-variable/geist": "5.3.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@xterm/addon-search": "^0.16.0", "electron-updater": "^6.8.9" @@ -785,6 +786,15 @@ "node": ">=18" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@homebridge/node-pty-prebuilt-multiarch": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", diff --git a/package.json b/package.json index 8adf48e..ce01323 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "vitest": "^3.2.6" }, "dependencies": { + "@fontsource-variable/geist": "5.3.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@xterm/addon-search": "^0.16.0", "electron-updater": "^6.8.9" diff --git a/qa/audit-views.mjs b/qa/audit-views.mjs new file mode 100644 index 0000000..195470a --- /dev/null +++ b/qa/audit-views.mjs @@ -0,0 +1,3 @@ +export function auditableViews(items) { + return items.filter(({ visible }) => visible).map(({ view }) => view); +} diff --git a/qa/audit-views.test.mjs b/qa/audit-views.test.mjs new file mode 100644 index 0000000..2bd3ba9 --- /dev/null +++ b/qa/audit-views.test.mjs @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { auditableViews } from './audit-views.mjs'; + +describe('auditableViews', () => { + it('excludes navigation items that exist but are hidden on the current platform', () => { + expect(auditableViews([ + { view: 'projects', visible: true }, + { view: 'cockpit', visible: false }, + ])).toEqual(['projects']); + }); +}); diff --git a/qa/audit.mjs b/qa/audit.mjs index 24f1ad4..90fb0e7 100644 --- a/qa/audit.mjs +++ b/qa/audit.mjs @@ -75,6 +75,116 @@ ipc.providerOpen = await win.evaluate(async (p) => { escapeClosed: menuButton?.getAttribute('aria-expanded') === 'false' && !!menu?.classList.contains('hidden'), }; }, root); +await win.click('.rail-item[data-view="projects"]'); +await win.waitForTimeout(150); +const displayTrigger = win.locator('#project-display'); +const displayMenu = win.locator('#project-display-menu'); +const displayItem = (id) => win.locator(`#${id}`); +const displayState = () => win.evaluate(() => { + const trigger = document.getElementById('project-display'); + const menu = document.getElementById('project-display-menu'); + return { open: trigger?.getAttribute('aria-expanded') === 'true' && !menu?.classList.contains('hidden'), focusId: document.activeElement?.id ?? '' }; +}); +const displayModel = await win.evaluate(() => { + const menu = document.getElementById('project-display-menu'); + const items = Array.from(menu?.querySelectorAll('[role^="menuitem"]') ?? []) + // The composite itself is closed while its static model is inspected. Exclude only choices in + // a nested hidden group; the menu's own `.hidden` state must not erase its actionable model. + .filter((item) => item instanceof HTMLButtonElement && !item.disabled && !item.closest('#agent-select-control.hidden')); + const selected = items.find((item) => item.getAttribute('role') === 'menuitemradio' && item.getAttribute('aria-checked') === 'true'); + return { + actionableIds: items.map((item) => item.id), + initialFocusId: selected?.id ?? items[0]?.id ?? '', + menuRole: menu?.getAttribute('role') === 'menu', + labeledItems: items.length >= 3 && items.every((item) => !!item.textContent?.trim()), + providerLabel: !!menu?.querySelector('#agent-select-label')?.textContent?.trim(), + providerGroup: menu?.querySelector('#agent-select-control')?.getAttribute('role') === 'group', + }; +}); + +await displayTrigger.focus(); +await win.keyboard.press('Space'); +let state = await displayState(); +const spaceOpened = state.open; +const focusOnOpen = state.focusId === displayModel.initialFocusId; +const rovingFocus = await win.evaluate(() => { + const items = Array.from(document.querySelectorAll('#project-display-menu [role^="menuitem"]')).filter((item) => !item.closest('.hidden')); + return items.filter((item) => item.tabIndex === 0).length === 1 && items.filter((item) => item.tabIndex === -1).length === items.length - 1; +}); +await win.keyboard.press('ArrowDown'); +state = await displayState(); +const arrowDown = state.focusId === displayModel.actionableIds[(displayModel.actionableIds.indexOf(displayModel.initialFocusId) + 1) % displayModel.actionableIds.length]; +await win.keyboard.press('ArrowUp'); +state = await displayState(); +const arrowUp = state.focusId === displayModel.initialFocusId; +await win.keyboard.press('Home'); +state = await displayState(); +const home = state.focusId === displayModel.actionableIds[0]; +await win.keyboard.press('End'); +state = await displayState(); +const end = state.focusId === displayModel.actionableIds.at(-1); + +await displayItem('view-cards').focus(); +await win.keyboard.press('Enter'); +state = await displayState(); +const itemActivateCloseFocus = !state.open && state.focusId === 'project-display'; + +await displayTrigger.click(); +await displayItem('view-list').click(); +state = await displayState(); +const itemClickCloseFocus = !state.open && state.focusId === 'project-display'; + +await displayTrigger.click(); +await win.locator('#proj-search').click(); +state = await displayState(); +const outsideClosed = !state.open; + +await displayTrigger.focus(); +await win.keyboard.press('Space'); +const agentSelect = win.locator('#agent-select'); +const agentOptions = await agentSelect.locator('option').count(); +const hiddenNativeBridge = await agentSelect.evaluate((select) => select.hidden && select.tabIndex === -1 && select.getAttribute('aria-hidden') === 'true'); +let providerProxySelection = agentOptions < 2 && await win.locator('#agent-select-control').evaluate((control) => control.classList.contains('hidden')); +if (agentOptions >= 2) { + const before = await agentSelect.inputValue(); + const target = win.locator(`#agent-select-options [role="menuitemradio"]:not([data-agent-id="${before}"])`).first(); + const targetId = await target.getAttribute('data-agent-id'); + await target.focus(); + await win.keyboard.press('Enter'); + await win.waitForTimeout(200); // the real provider-change handler refreshes the deck asynchronously + state = await displayState(); + providerProxySelection = !state.open && state.focusId === 'project-display' && await agentSelect.inputValue() === targetId + && await win.locator(`#agent-select-options [data-agent-id="${targetId}"]`).getAttribute('aria-checked') === 'true'; +} +await displayTrigger.focus(); +await win.keyboard.press('Space'); +await win.keyboard.press('Escape'); +state = await displayState(); +const escapeClosed = !state.open && state.focusId === 'project-display'; + +ipc.projectDisplay = { + trigger: await displayTrigger.count() === 1, + hasPopup: await displayTrigger.getAttribute('aria-haspopup') === 'menu', + initialExpanded: await displayTrigger.getAttribute('aria-expanded') === 'false', + spaceOpened, + menuRole: displayModel.menuRole, + labeledItems: displayModel.labeledItems, + providerLabel: displayModel.providerLabel, + providerGroup: displayModel.providerGroup, + hiddenNativeBridge, + providerProxySelection, + showHiddenRole: await displayItem('show-hidden').getAttribute('role') === 'menuitemcheckbox', + focusOnOpen, + rovingFocus, + arrowDown, + arrowUp, + home, + end, + itemActivateCloseFocus, + itemClickCloseFocus, + outsideClosed, + escapeClosed, +}; ipc.usageShape = await win.evaluate(async () => { const r = await window.devdeck.usageReport(0); return { hasGlobal: !!r.global, hasByProject: Array.isArray(r.byProject), hasByModel: Array.isArray(r.byModel), hasByProvider: Array.isArray(r.byProvider) && r.byProvider.length === 2 }; @@ -92,15 +202,29 @@ ipc.errorToast = await (async () => { ipc.surface = await win.evaluate(() => ({ openFolder: typeof window.devdeck.openFolder === 'function', + noCockpitDestination: document.querySelectorAll('.rail-item[data-view="cockpit"]').length === 0, windowControls: !!window.devdeck.windowControls && ['minimize', 'toggleMaximize', 'close', 'isMaximized', 'onMaximizeChange'] .every((k) => typeof window.devdeck.windowControls[k] === 'function'), })); -ipc.titlebar = await win.evaluate(() => ({ +// xvfb-run provides a display but no window manager, so BrowserWindow.maximize() may never change +// state or emit `maximize` on Linux CI. Drive Electron's real main -> preload -> renderer event path +// directly; the IPC methods themselves are covered by `ipc.surface.windowControls` above. +await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('unmaximize')); +await win.waitForTimeout(50); +const maximizeLabel = await win.evaluate(() => ({ title: document.getElementById('win-max')?.title, aria: document.getElementById('win-max')?.getAttribute('aria-label') })); +await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('maximize')); +await win.waitForTimeout(50); +const restoreLabel = await win.evaluate(() => ({ title: document.getElementById('win-max')?.title, aria: document.getElementById('win-max')?.getAttribute('aria-label') })); +await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('unmaximize')); +ipc.titlebar = { + ...await win.evaluate(() => ({ logo: !!document.querySelector('.tb-logo'), controls: ['win-min', 'win-max', 'win-close'].every((id) => !!document.getElementById(id)), closeLabeled: document.getElementById('win-close')?.getAttribute('aria-label') === 'Close', -})); + })), + maximizeStates: !!maximizeLabel.title && maximizeLabel.aria === maximizeLabel.title && !!restoreLabel.title && restoreLabel.aria === restoreLabel.title && maximizeLabel.title !== restoreLabel.title, +}; // --- axe a11y per view (inject axe-core source directly; Electron CDP lacks Target.createTarget) --- const a11y = {}; @@ -122,12 +246,23 @@ const localUsageReport = { byProject: [{ path: 'C:/qa/app', name: 'qa-app', sessions: 1, totals: { input: 200, output: 60, cacheWrite: 0, cacheRead: 80 }, costEstimate: 1.5, hasUnknownModel: false, activeMs: 600000, status: 'active', providerCosts: { codex: 1.5 } }], daily: [] }, ], }; -// next + cockpit included — the two newest, most dynamic views were previously never axe-checked. -// cockpit's rail item only exists on win32, so absent views are skipped (CI runs this on Linux). -for (const view of ['projects', 'usage', 'settings', 'next', 'cockpit']) { - const present = await win.evaluate((v) => !!document.querySelector(`.rail-item[data-view="${v}"]`), view); - if (!present) continue; - await win.click(`.rail-item[data-view="${view}"]`); +// Cockpit is internal: audit it only through a shared-shell session route when the Windows-only +// implementation is available. The other views retain their rail destinations. +const viewCandidates = ['projects', 'usage', 'settings', 'next']; +const viewStates = await Promise.all(viewCandidates.map(async (view) => ({ + view, + selector: `.rail-item[data-view="${view}"]`, + visible: await win.locator(`.rail-item[data-view="${view}"]`).isVisible().catch(() => false), +}))); +const cockpitAvailable = await win.evaluate(() => !document.getElementById('shell-session-section')?.classList.contains('hidden')); +if (cockpitAvailable) { + await win.evaluate(() => document.dispatchEvent(new CustomEvent('devdeck:qa-shell-sessions', { detail: [ + { id: 'qa-audit-cockpit', projectPath: 'C:/qa/cockpit', label: 'QA Cockpit route', detail: 'main · Claude', activity: 'idle', pinned: false }, + ] }))); + viewStates.push({ view: 'cockpit', selector: '#shell-session-groups .shell-session', visible: await win.locator('#shell-session-groups .shell-session').first().isVisible().catch(() => false) }); +} +for (const { view, selector } of viewStates.filter(({ visible }) => visible)) { + await win.click(selector); await win.waitForTimeout(600); if (view === 'usage') { await win.evaluate((report) => document.dispatchEvent(new CustomEvent('devdeck:local-usage-report', { detail: report })), localUsageReport); @@ -173,6 +308,19 @@ ipc.memoryDialog = await win.evaluate(() => { }; }); if (ipc.memoryDialog.present) { + const providerButton = win.locator('.pm-modal .provider-open-menu-button'); + await providerButton.focus(); + await providerButton.click(); + await win.waitForSelector('.pm-modal .provider-open-menu:not(.hidden)', { timeout: 3000 }).catch(() => {}); + await win.keyboard.press('Escape'); + ipc.memoryDialog.innerEscapePriority = await win.locator('.pm-modal').count() === 1 + && await win.locator('.pm-modal .provider-open-menu.hidden').count() === 1 + && await providerButton.evaluate((button) => document.activeElement === button); + await providerButton.focus(); + await win.keyboard.press('Tab'); + ipc.memoryDialog.forwardTabWrap = await win.locator('.pm-refresh').evaluate((button) => document.activeElement === button); + await win.keyboard.press('Shift+Tab'); + ipc.memoryDialog.reverseTabWrap = await providerButton.evaluate((button) => document.activeElement === button); await win.evaluate(axeCore.source); const res = await win.evaluate(async () => // eslint-disable-next-line no-undef @@ -245,9 +393,10 @@ const gitInfoFail = ipc.cockpitGitInfo !== true; const dialogFails = Object.entries(ipc.usageDialog ?? {}).filter(([k, v]) => (k === 'sections' ? v !== 3 : v === false)); const providerOpenFail = !ipc.providerOpen?.root || !ipc.providerOpen.primaryLabel || !ipc.providerOpen.menuLabel || !ipc.providerOpen.menuOpened || ipc.providerOpen.menuItems < 2 || !ipc.providerOpen.escapeClosed; +const projectDisplayFail = Object.values(ipc.projectDisplay ?? {}).some((value) => value !== true); const memoryDialogFail = Object.values(ipc.memoryDialog ?? {}).some((v) => v !== true); -if (criticalViolations.length > 0 || surfaceFails.length > 0 || titlebarFails.length > 0 || gitInfoFail || dialogFails.length > 0 || providerOpenFail || memoryDialogFail) { +if (criticalViolations.length > 0 || surfaceFails.length > 0 || titlebarFails.length > 0 || gitInfoFail || dialogFails.length > 0 || providerOpenFail || projectDisplayFail || memoryDialogFail) { console.error('QA FAILED:'); if (criticalViolations.length > 0) console.error(' a11y critical/serious:', JSON.stringify(criticalViolations, null, 2)); if (surfaceFails.length > 0) console.error(' ipc.surface checks failed:', surfaceFails.map(([k]) => k).join(', ')); @@ -255,6 +404,7 @@ if (criticalViolations.length > 0 || surfaceFails.length > 0 || titlebarFails.le if (gitInfoFail) console.error(' cockpit.gitInfo did not resolve a branch for the repo root:', ipc.cockpitGitInfo, '· raw gitInfo:', JSON.stringify(gitInfoRaw)); if (dialogFails.length > 0) console.error(' usage dialog checks failed:', JSON.stringify(ipc.usageDialog)); if (providerOpenFail) console.error(' provider open control missing or unlabeled:', JSON.stringify(ipc.providerOpen)); + if (projectDisplayFail) console.error(' project Display menu is inaccessible or incomplete:', JSON.stringify(ipc.projectDisplay)); if (memoryDialogFail) console.error(' project memory dialog checks failed:', JSON.stringify(ipc.memoryDialog)); process.exit(1); } diff --git a/qa/screenshot.mjs b/qa/screenshot.mjs index d109296..1a15a8f 100644 --- a/qa/screenshot.mjs +++ b/qa/screenshot.mjs @@ -13,9 +13,24 @@ mkdirSync(out, { recursive: true }); const consoleErrors = []; const pageErrors = []; -// Isolated user-data-dir so the single-instance lock never makes this launch quit. +// Isolated user-data-dir so the single-instance lock never makes this launch quit. Seed three legacy +// previous sessions: two id-less siblings exercise identity migration, while the fake named id gives +// the real missing-conversation check a deterministic warning row. +const qaUserData = mkdtempSync(join(tmpdir(), 'devdeck-qa-')); +writeFileSync(join(qaUserData, 'state.json'), JSON.stringify({ + projects: {}, + settings: { + folders: [{ path: root, kind: 'repo' }], + viewMode: 'list', + cockpitSessions: [ + { projectPath: root, name: 'devdeck', sessionId: null, agentId: 'claude', label: 'Legacy id-less A' }, + { projectPath: root, name: 'devdeck', sessionId: null, agentId: 'claude', label: 'Legacy id-less B' }, + { projectPath: root, name: 'devdeck', sessionId: 'qa-conversation-is-gone', agentId: 'claude', label: 'Missing conversation' }, + ], + }, +}, null, 2)); const app = await electron.launch({ - args: ['.', `--user-data-dir=${mkdtempSync(join(tmpdir(), 'devdeck-qa-'))}`, '--no-sandbox', '--disable-gpu'], + args: ['.', `--user-data-dir=${qaUserData}`, '--no-sandbox', '--disable-gpu'], cwd: root, }); const win = await app.firstWindow(); @@ -40,6 +55,13 @@ async function showView(v) { await win.click(`.rail-item[data-view="${v}"]`); await win.waitForTimeout(300); } +async function showCockpitViaSession() { + const session = win.locator('#shell-session-groups .shell-session').first(); + if (!await session.isVisible().catch(() => false)) return false; + await session.click(); + await win.waitForTimeout(300); + return true; +} // Deterministic local-history fixture. Real home-directory logs vary between machines, so the Usage // page receives a representative combined report through its renderer QA seam. @@ -84,8 +106,115 @@ async function injectLocalUsage() { // wait for first project render (skeleton -> cards), generous for git scan await win.waitForSelector('#cards .card, #cards .empty', { timeout: 30000 }).catch(() => {}); +const cockpitAvailable = await win.evaluate(() => !document.getElementById('shell-session-section')?.classList.contains('hidden')); + +// The internal Cockpit route must never regain a user-facing rail destination, including on platforms +// where embedded PTYs are unavailable. +const cockpitDestinationCount = await win.locator('.rail-item[data-view="cockpit"]').count(); +if (cockpitDestinationCount !== 0) { + console.error(`QA FAILED — expected zero standalone Cockpit destinations, found ${cockpitDestinationCount}`); + await closeApp(); process.exit(1); +} + +// The expanded command-center sidebar must not inherit the old 36px icon-rail geometry. +// A cascade-order regression makes localized labels spill vertically outside their buttons while +// the overall sidebar still has the expected width, so inspect each navigation item itself. +const shellNavGeometry = await win.evaluate(() => { + const sidebar = document.getElementById('app-sidebar'); + const items = Array.from(document.querySelectorAll('#app-sidebar .rail-item')); + return { + present: !!sidebar && items.length >= 5, + width: sidebar?.getBoundingClientRect().width ?? 0, + overflow: items.some((item) => item.scrollWidth > item.clientWidth + 1 || item.scrollHeight > item.clientHeight + 1), + }; +}); +console.log('shell navigation geometry:', JSON.stringify(shellNavGeometry)); +if (!shellNavGeometry.present || shellNavGeometry.width < 200 || shellNavGeometry.width > 240 || shellNavGeometry.overflow) { + console.error('QA FAILED — expanded shell navigation is clipped:', JSON.stringify(shellNavGeometry)); + await closeApp(); process.exit(1); +} + +const shellGeometry = await win.evaluate(() => { + const shell = document.getElementById('shell')?.getBoundingClientRect(); + const sidebar = document.getElementById('app-sidebar')?.getBoundingClientRect(); + const content = document.getElementById('content')?.getBoundingClientRect(); + return { + present: !!shell && !!sidebar && !!content, + contained: !!shell && !!sidebar && !!content && sidebar.left >= shell.left && content.right <= shell.right, + overlap: !!sidebar && !!content && sidebar.right > content.left + 1, + }; +}); +if (!shellGeometry.present || !shellGeometry.contained || shellGeometry.overlap) { + console.error('QA FAILED — shared shell geometry is invalid:', JSON.stringify(shellGeometry)); + await closeApp(); process.exit(1); +} + +// Previous-session management must live in the shared shell before the hidden compatibility list. +// Exercise real persisted entries and the real Cockpit pin/forget handlers (no DOM-only mock). +if (cockpitAvailable) { + await win.waitForSelector('.shell-session-wrap[data-previous="true"]', { timeout: 10000 }).catch(() => {}); + await win.waitForFunction(() => !!document.querySelector('.shell-session-wrap[data-conversation-gone="true"]'), null, { timeout: 10000 }).catch(() => {}); + const previousShell = await win.evaluate(() => { + const rows = Array.from(document.querySelectorAll('.shell-session-wrap[data-previous="true"]')); + const keys = rows.map((wrap) => wrap.querySelector('.shell-session')?.getAttribute('data-shell-entity-key')); + const warning = document.querySelector('.shell-session-wrap[data-conversation-gone="true"]'); + return { + count: rows.length, + uniqueKeys: new Set(keys).size, + restoreAll: !!document.querySelector('#shell-restore-all:not(.hidden)'), + restoreCopy: rows.every((wrap) => !!wrap.querySelector('.shell-session small')?.textContent?.trim()), + warningVisible: !!warning?.querySelector('.shell-session-warning'), + menus: rows.every((wrap) => wrap.querySelector('.shell-session-actions')?.getAttribute('aria-haspopup') === 'menu'), + }; + }); + if (previousShell.count !== 3 || previousShell.uniqueKeys !== 3 || !previousShell.restoreAll || !previousShell.restoreCopy || !previousShell.warningVisible || !previousShell.menus) { + console.error('QA FAILED — shared-shell previous-session controls are incomplete:', JSON.stringify(previousShell)); + await closeApp(); process.exit(1); + } + + const firstPrevious = win.locator('.shell-session-wrap[data-previous="true"]').first(); + const firstKey = await firstPrevious.locator('.shell-session').getAttribute('data-shell-entity-key'); + await firstPrevious.locator('.shell-session-actions').focus(); + await win.keyboard.press('Enter'); + await firstPrevious.locator('[data-session-action="pin"]').press('Enter'); + await win.waitForTimeout(150); + const pinPersisted = await win.evaluate(async (key) => { + const tileId = decodeURIComponent(String(key).replace(/^session:tile:/, '')); + return (await window.devdeck.cockpit.loadSessions()).some((entry) => entry.tileId === tileId && entry.pinned === true); + }, firstKey); + + const forgetTarget = win.locator('.shell-session-wrap[data-previous="true"]').filter({ hasText: 'Legacy id-less B' }); + const beforeForget = await win.locator('.shell-session-wrap[data-previous="true"]').count(); + await forgetTarget.locator('.shell-session-actions').click(); + await forgetTarget.locator('[data-session-action="forget"]').click(); + await win.waitForTimeout(150); + const afterForget = await win.locator('.shell-session-wrap[data-previous="true"]').count(); + if (!pinPersisted || afterForget !== beforeForget - 1) { + console.error('QA FAILED — shared-shell pin/forget did not route through Cockpit handlers:', JSON.stringify({ pinPersisted, beforeForget, afterForget })); + await closeApp(); process.exit(1); + } +} + +await win.click('#shell-collapse'); +await win.waitForTimeout(180); +const collapsedShell = await win.evaluate(() => { + const sidebar = document.getElementById('app-sidebar'); + return { + collapsed: sidebar?.classList.contains('collapsed') === true, + width: Math.round(sidebar?.getBoundingClientRect().width ?? 0), + labelsHidden: Array.from(document.querySelectorAll('#app-sidebar .rail-label')).every((label) => getComputedStyle(label).display === 'none'), + }; +}); +await shot('shell-collapsed'); +if (!collapsedShell.collapsed || collapsedShell.width !== 52 || !collapsedShell.labelsHidden) { + console.error('QA FAILED — collapsed shell geometry is invalid:', JSON.stringify(collapsedShell)); + await closeApp(); process.exit(1); +} +await win.click('#shell-collapse'); +await win.waitForTimeout(180); const LANGS = ['ko', 'en', 'ja', 'zh']; +const RESTORE_LABELS = { ko: '복원', en: 'Restore', ja: '復元', zh: '恢复' }; for (let i = 0; i < LANGS.length; i++) { const l = await lang(); // Projects view @@ -157,9 +286,11 @@ await win.keyboard.press('Escape').catch(() => {}); await win.waitForTimeout(200); // Compact list view toggle (+ GitHub octocat on rows for repos with a github remote) +await win.click('#project-display'); await win.click('#view-list').catch(() => {}); await win.waitForSelector('#cards.as-list .prow', { timeout: 5000 }).catch(() => {}); await shot('projects-list-view'); +await win.click('#project-display'); await win.click('#view-cards').catch(() => {}); await win.waitForTimeout(300); @@ -181,16 +312,99 @@ if (reuse.total > 0 && reuse.survived === 0) { process.exit(1); } -// Narrow window to check responsive card grid -await win.setViewportSize({ width: 520, height: 760 }).catch(() => {}); +// Narrow window to check responsive card grid. Switch language at desktop width first because the +// narrow shell deliberately hides its language trigger, then inspect the localized toolbar at 520px. +const displayMenuGeometry = []; +for (const target of LANGS) { + if (await lang() !== target) { + await win.setViewportSize({ width: 1000, height: 720 }).catch(() => {}); + await win.click('#lang-btn'); + await win.click(`.lang-menu .menu-item[data-lang="${target}"]`); + await win.waitForTimeout(120); + } + await showView('projects'); + await win.click('#project-display'); + await win.click('#view-list'); + await win.waitForSelector('#cards.as-list .prow', { timeout: 10000 }); + await win.setViewportSize({ width: 520, height: 760 }).catch(() => {}); + await win.waitForTimeout(180); // wait for the sidebar's width transition before measuring content geometry + await win.click('#project-display'); + const geometry = await win.evaluate((restoreLabel) => { + const toolbar = document.querySelector('#view-projects .view-toolbar'); + const menu = document.getElementById('project-display-menu'); + const rect = menu?.getBoundingClientRect(); + const previousDetail = document.querySelector('.shell-session-wrap[data-previous="true"]:not([data-conversation-gone="true"]) .shell-session small'); + return { + language: document.documentElement.lang, + toolbarOverflow: !!toolbar && toolbar.scrollWidth > toolbar.clientWidth + 1, + menuContained: !!rect && rect.left >= 0 && rect.right <= innerWidth + 1, + menuOverflow: !!menu && menu.scrollWidth <= menu.clientWidth + 1, + listOverflow: document.getElementById('view-projects').scrollWidth > document.getElementById('view-projects').clientWidth + 1, + rowContained: Array.from(document.querySelectorAll('.prow')).every((row) => row.getBoundingClientRect().left >= 0 && row.getBoundingClientRect().right <= innerWidth + 1), + stateVisible: document.querySelectorAll('.prow-state').length === document.querySelectorAll('.prow').length && Array.from(document.querySelectorAll('.prow-state')).every((state) => state.getClientRects().length > 0 && !!state.querySelector('.prow-state-text')?.textContent?.trim() && !!state.querySelector('.prow-state-shape')), + openVisible: Array.from(document.querySelectorAll('.prow .provider-open-primary')).every((button) => button.getClientRects().length > 0 && !!button.querySelector('.provider-open-primary-text')?.textContent?.trim()), + shellDetailLocalized: !!previousDetail?.textContent?.includes(restoreLabel), + }; + }, RESTORE_LABELS[target]); + displayMenuGeometry.push(geometry); + await win.keyboard.press('Escape'); +} +console.log('narrow Display menu geometry:', JSON.stringify(displayMenuGeometry)); +if (displayMenuGeometry.some((entry) => entry.toolbarOverflow || !entry.menuContained || !entry.menuOverflow || entry.listOverflow || !entry.rowContained || !entry.stateVisible || !entry.openVisible || !entry.shellDetailLocalized)) { + console.error('QA FAILED — narrow Display controls overflow:', JSON.stringify(displayMenuGeometry)); + await closeApp(); process.exit(1); +} +await win.click('#project-display'); +await shot('projects-display-menu-narrow'); +await win.keyboard.press('Escape'); await shot('projects-narrow'); -// Title bar: maximized state (restore glyph) +// At supported narrow widths, the shared session navigation remains reachable as an overlay drawer. +if (cockpitAvailable) { + await win.evaluate(() => document.dispatchEvent(new CustomEvent('devdeck:qa-shell-sessions', { detail: [ + { id: 'qa-mobile-attention', projectPath: 'C:/qa/mobile', label: 'Mobile session', detail: 'main · Claude', activity: 'attention', pinned: false }, + ] }))); + const mobileTrigger = win.locator('#shell-mobile-toggle'); + await mobileTrigger.focus(); + await mobileTrigger.click(); + const mobileOpen = await win.evaluate(() => ({ + open: document.getElementById('app-sidebar')?.classList.contains('mobile-open') === true, + expanded: document.getElementById('shell-mobile-toggle')?.getAttribute('aria-expanded') === 'true', + count: document.getElementById('shell-mobile-toggle')?.textContent?.includes('1') === true, + rowVisible: document.querySelector('.shell-session')?.getClientRects().length > 0, + })); + await win.keyboard.press('Escape'); + const mobileEscaped = await mobileTrigger.evaluate((button) => document.activeElement === button && button.getAttribute('aria-expanded') === 'false'); + await mobileTrigger.click(); + await win.locator('.shell-session').first().click(); + const mobileSelected = await win.evaluate(() => ({ + closed: document.getElementById('app-sidebar')?.classList.contains('mobile-open') !== true, + cockpit: document.getElementById('view-cockpit')?.classList.contains('active') === true, + })); + if (!mobileOpen.open || !mobileOpen.expanded || !mobileOpen.count || !mobileOpen.rowVisible || !mobileEscaped || !mobileSelected.closed || !mobileSelected.cockpit) { + console.error('QA FAILED — narrow shared-session drawer is not keyboard/pointer reachable:', JSON.stringify({ mobileOpen, mobileEscaped, mobileSelected })); + await closeApp(); process.exit(1); + } +} + +// Title bar: both accessible action states track the maximize state and current locale. await win.setViewportSize({ width: 1000, height: 720 }).catch(() => {}); +if (await win.evaluate(() => window.devdeck.windowControls.isMaximized())) await win.evaluate(() => window.devdeck.windowControls.toggleMaximize()); +await win.waitForTimeout(250); +const maximizeBefore = await win.evaluate(() => ({ title: document.getElementById('win-max')?.title, aria: document.getElementById('win-max')?.getAttribute('aria-label') })); await win.evaluate(() => window.devdeck.windowControls.toggleMaximize()); await win.waitForTimeout(400); +const maximizeAfter = await win.evaluate(() => ({ title: document.getElementById('win-max')?.title, aria: document.getElementById('win-max')?.getAttribute('aria-label') })); await shot('titlebar-maximized'); await win.evaluate(() => window.devdeck.windowControls.toggleMaximize()); +const maximizeLabels = { + en: ['Maximize', 'Restore'], ko: ['최대화', '복원'], ja: ['最大化', '元に戻す'], zh: ['最大化', '还原'], +}[await lang()]; +if (!maximizeLabels || maximizeBefore.title !== maximizeLabels[0] || maximizeBefore.aria !== maximizeBefore.title + || maximizeAfter.title !== maximizeLabels[1] || maximizeAfter.aria !== maximizeAfter.title) { + console.error('QA FAILED — maximize title/aria-label is stale:', JSON.stringify({ maximizeBefore, maximizeAfter, maximizeLabels })); + await closeApp(); process.exit(1); +} // Next task board: seed one isolated-profile task so the provider-aware split Open control is rendered. await app.evaluate(({ dialog }, p) => { @@ -208,30 +422,120 @@ const taskSeeded = await win.evaluate(async () => { return true; }); +// Shell reconciliation must keep the exact focused project row through the real project refresh +// path. A replace-children implementation would detach the button and lose keyboard focus. +if (!taskSeeded) { + console.error('QA FAILED — unable to seed a project for shell refresh reconciliation.'); + await closeApp(); process.exit(1); +} +await showView('projects'); +await win.click('#refresh'); +await win.waitForSelector('#shell-projects .shell-project', { timeout: 10000 }); +const shellRefresh = await win.evaluate(async () => { + const before = document.querySelector('#shell-projects .shell-project'); + before.focus(); + const key = before.dataset.shellEntityKey; + document.getElementById('refresh').click(); + await new Promise((r) => setTimeout(r, 2500)); + const after = document.querySelector(`#shell-projects .shell-project[data-shell-entity-key="${CSS.escape(key)}"]`); + return { sameNode: before === after, focused: document.activeElement === after }; +}); +console.log(`shell refresh reuse: sameNode=${shellRefresh.sameNode} focused=${shellRefresh.focused}`); +if (!shellRefresh.sameNode || !shellRefresh.focused) { + console.error('QA FAILED — shell refresh replaced or defocused an unchanged project row.'); + await closeApp(); process.exit(1); +} + // Project Memory: the same real allowed checkout supplies recent commits and the seeded task. Capture // both normal and narrow geometry, and fail if the modal itself overflows horizontally. await showView('projects'); -await win.click('#refresh'); await win.waitForSelector('.project-memory-button', { timeout: 10000 }); -await win.locator('.project-memory-button').first().click(); +await win.click('#project-display'); +await win.click('#view-list'); +await win.waitForSelector('#cards.as-list .prow', { timeout: 5000 }); +const populatedProjectGeometry = await win.evaluate(() => { + const view = document.getElementById('view-projects'); + const row = view?.querySelector('.prow')?.getBoundingClientRect(); + const content = document.getElementById('content')?.getBoundingClientRect(); + return { + overflow: !!view && view.scrollWidth > view.clientWidth + 1, + rowContained: !!row && !!content && row.left >= content.left - 1 && row.right <= content.right + 1, + }; +}); +if (populatedProjectGeometry.overflow || !populatedProjectGeometry.rowContained) { + console.error('QA FAILED — populated project row overflows the command-center content:', JSON.stringify(populatedProjectGeometry)); + await closeApp(); process.exit(1); +} +await shot('projects-populated'); +const memoryTrigger = win.locator('.project-memory-button').first(); +const contentBeforeMemory = await win.evaluate(() => { + const r = document.getElementById('content').getBoundingClientRect(); + return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; +}); +await memoryTrigger.focus(); +await memoryTrigger.click(); await win.waitForSelector('.pm-modal:not(.loading) .pm-timeline-item', { timeout: 10000 }); +const memoryWide = await win.evaluate(() => { + const modal = document.querySelector('.pm-modal'); + const r = modal?.getBoundingClientRect(); + const c = document.getElementById('content').getBoundingClientRect(); + return { + surface: modal?.dataset.surface, + rightAligned: !!r && Math.abs(r.right - window.innerWidth) <= 1, + content: [Math.round(c.x), Math.round(c.y), Math.round(c.width), Math.round(c.height)], + }; +}); await shot('project-memory'); +const memoryProviderButton = win.locator('.pm-modal .provider-open-menu-button'); +await memoryProviderButton.focus(); +await memoryProviderButton.click(); +await win.waitForSelector('.pm-modal .provider-open-menu:not(.hidden)', { timeout: 3000 }); +await win.keyboard.press('Escape'); +const memoryEscapePriority = await win.evaluate(() => ({ + drawerOpen: !!document.querySelector('.pm-modal'), + providerClosed: !!document.querySelector('.pm-modal .provider-open-menu.hidden'), + focusReturned: document.activeElement?.classList.contains('provider-open-menu-button') === true, +})); +await memoryProviderButton.focus(); // last visible focusable after the provider popup is closed +await win.keyboard.press('Tab'); +const memoryForwardWrap = await win.locator('.pm-refresh').evaluate((button) => document.activeElement === button); +await win.keyboard.press('Shift+Tab'); +const memoryReverseWrap = await memoryProviderButton.evaluate((button) => document.activeElement === button); +await memoryProviderButton.click(); +await win.waitForSelector('.pm-modal .provider-open-menu:not(.hidden)', { timeout: 3000 }); +await win.keyboard.press('Escape'); +const memoryStillOpenAfterInnerEscape = await win.locator('.pm-modal').count() === 1; +if (!memoryEscapePriority.drawerOpen || !memoryEscapePriority.providerClosed || !memoryEscapePriority.focusReturned + || !memoryForwardWrap || !memoryReverseWrap || !memoryStillOpenAfterInnerEscape) { + console.error('QA FAILED — Project Memory nested provider focus/Escape containment regressed:', JSON.stringify({ memoryEscapePriority, memoryForwardWrap, memoryReverseWrap, memoryStillOpenAfterInnerEscape })); + await closeApp(); process.exit(1); +} await win.setViewportSize({ width: 520, height: 760 }).catch(() => {}); await win.waitForTimeout(150); const memoryGeometry = await win.evaluate(() => { const modal = document.querySelector('.pm-modal'); return { present: !!modal, + surface: modal?.dataset.surface, overflow: !!modal && modal.scrollWidth > modal.clientWidth + 1, + fullWidth: !!modal && Math.abs(modal.getBoundingClientRect().width - window.innerWidth) <= 1, + contained: !!modal && modal.getBoundingClientRect().left >= 0 && modal.getBoundingClientRect().right <= window.innerWidth + 1, events: document.querySelectorAll('.pm-timeline-item').length, }; }); await shot('project-memory-narrow'); -if (!memoryGeometry.present || memoryGeometry.overflow || memoryGeometry.events < 1) { - console.error('QA FAILED — Project Memory modal missing, empty, or horizontally clipped:', JSON.stringify(memoryGeometry)); +if (memoryWide.surface !== 'drawer' || !memoryWide.rightAligned || JSON.stringify(memoryWide.content) !== JSON.stringify(contentBeforeMemory) + || !memoryGeometry.present || memoryGeometry.surface !== 'sheet' || !memoryGeometry.fullWidth || !memoryGeometry.contained + || memoryGeometry.overflow || memoryGeometry.events < 1) { + console.error('QA FAILED — Project Memory drawer/sheet geometry regressed:', JSON.stringify({ memoryWide, memoryGeometry, contentBeforeMemory })); await closeApp(); process.exit(1); } await win.keyboard.press('Escape'); +const memoryFocusReturned = await memoryTrigger.evaluate((el) => document.activeElement === el).catch(() => false); +if (!memoryFocusReturned) { + console.error('QA FAILED — Project Memory did not return focus to its trigger after Escape.'); + await closeApp(); process.exit(1); +} await win.setViewportSize({ width: 1000, height: 720 }).catch(() => {}); await showView('next'); await win.waitForSelector('#view-next .provider-open, #view-next .empty', { timeout: 5000 }).catch(() => {}); @@ -270,8 +574,15 @@ await win.click('#view-next .cal-cell.today').catch(() => {}); await shot('next-calendar'); await win.click('#view-next .tk-vt:nth-child(1)').catch(() => {}); // back to list for later scenes -// Cockpit view: navigate and capture the empty state (no PTY spawned in the harness) -await showView('cockpit'); +// Cockpit is an internal route: enter through the real shared-shell session path when its Windows +// implementation is available, and skip the platform-specific checks elsewhere. +if (cockpitAvailable) { +await win.evaluate(() => { + document.dispatchEvent(new CustomEvent('devdeck:qa-shell-sessions', { detail: [ + { id: 'qa-cockpit-route', projectPath: 'C:/qa/route', label: 'QA Cockpit route', detail: 'main · Claude', activity: 'idle', pinned: false }, + ] })); +}); +await showCockpitViaSession(); await win.waitForSelector('#ck-empty', { timeout: 5000 }).catch(() => {}); await shot('cockpit'); @@ -289,17 +600,6 @@ if (ckFill.ratio < 0.8) { process.exit(1); } -const badgeHidden = await win.evaluate(() => { - const b = document.getElementById('ck-badge'); - return !b || b.classList.contains('hidden'); -}); -console.log(`cockpit badge hidden at zero needs-you: ${badgeHidden}`); -if (!badgeHidden) { - console.error('QA FAILED — rail badge visible with no needs-you sessions'); - await closeApp(); - process.exit(1); -} - // Cockpit structure intact after the multi-session changes (the + New session button only appears // with a live session, which the harness can't spawn — so just confirm the view renders cleanly). const ckOk = await win.evaluate(() => { @@ -310,93 +610,75 @@ const ckOk = await win.evaluate(() => { console.log(`cockpit structure + new-session button present: ${ckOk}`); if (!ckOk) { console.error('QA FAILED — cockpit structure / + New session button missing'); await closeApp(); process.exit(1); } -// Provider marks + two-line names: the sidebar must stay 250px, a very long ASCII name and an -// unbroken CJK name must clamp at two lines (never widen the sidebar or spill), and the hover-only -// row actions must reserve no width while hidden. The harness can't spawn a live PTY session, so -// inject representative row markup and measure the real CSS. +// Unified session navigation: long names/details must stay inside the shared 224px sidebar and +// the old nested Cockpit sidebar must not consume any terminal width. The harness cannot spawn a +// live PTY session, so send a representative fixture through the mounted shell controller's narrow +// renderer-local QA seam and inspect its real reconciliation output. const sidebar = await win.evaluate(async () => { - const groups = document.getElementById('ck-groups'); + const groups = document.getElementById('shell-session-groups'); const long = 'devdeck-monorepo-frontend-experimental-feature-branch-session-42-x'; const cjk = '데브덱코크핏세션이름아주아주긴한글이름테스트용으로만든것'; - // Line 3 (.sm) is the auto summary — deliberately longer than the sidebar so the clamp is exercised. - const summary = 'cockpitView.ts에 세션 요약 줄을 붙이고 CSS와 i18n을 정리하는 중'; - const rowHtml = (name, logo) => `
- - -
${name}🧠41%
main · Opus
${summary}
-
`; - groups.innerHTML = rowHtml(long, 'claude') + rowHtml(cjk, 'codex') - // A restorable entry whose conversation is gone: its meta line warns in the accent colour, since - // restoring it opens a FRESH session under the same name. - + `
${long}
⚠ conversation gone
`; + const fixture = [ + { id: 'qa-shell-attention', projectPath: 'C:/qa/attention', label: long, detail: 'main · Claude · 41%', activity: 'attention', pinned: false }, + { id: 'qa-shell-working', projectPath: 'C:/qa/working', label: cjk, detail: 'feature/command-center · Codex · 82%', activity: 'working', pinned: false }, + ]; + document.dispatchEvent(new CustomEvent('devdeck:qa-shell-sessions', { detail: fixture })); + await new Promise((r) => setTimeout(r, 250)); + const selectedRow = groups.querySelector('.group-attention .shell-session'); + selectedRow.focus(); selectedRow.click(); + document.dispatchEvent(new CustomEvent('devdeck:qa-shell-sessions', { detail: fixture })); await new Promise((r) => setTimeout(r, 250)); - const list = document.querySelector('#view-cockpit .ck-list').getBoundingClientRect(); - const names = [...document.querySelectorAll('#ck-groups .nm')]; - const lh = parseFloat(getComputedStyle(names[0]).lineHeight); - const logos = document.querySelectorAll('#ck-groups .ck-provider-logo'); - const loaded = [...logos].every((i) => i.complete && i.naturalWidth > 0); - const sums = [...document.querySelectorAll('#ck-groups .sm')]; - const sumLh = sums.length ? parseFloat(getComputedStyle(sums[0]).lineHeight) || 16 : 0; + const list = document.getElementById('app-sidebar').getBoundingClientRect(); + const names = [...groups.querySelectorAll('strong')]; + const details = [...groups.querySelectorAll('small')]; + const nested = document.querySelector('#view-cockpit .ck-list'); + const main = document.querySelector('#view-cockpit .ck-main').getBoundingClientRect(); + const wrap = document.querySelector('#view-cockpit .ck-wrap').getBoundingClientRect(); return { sidebarWidth: Math.round(list.width), - twoLines: names.every((n) => n.getBoundingClientRect().height <= lh * 2 + 1), inside: names.every((n) => n.getBoundingClientRect().right <= list.right + 1), - logos: logos.length, - loaded, - actsHidden: getComputedStyle(document.querySelector('#ck-groups .ck-row-acts')).opacity === '0', - // The summary line must stay ONE clipped line inside the sidebar — it is long by construction here. - summaries: sums.length, - summaryOneLine: sums.every((s) => s.getBoundingClientRect().height <= sumLh + 1), - summaryInside: sums.every((s) => s.getBoundingClientRect().right <= list.right + 1), - summaryClipped: sums.every((s) => s.scrollWidth > s.clientWidth), // actually overflowing → ellipsis in play - rowHeight: Math.round(document.querySelector('#ck-groups .ck-row').getBoundingClientRect().height), - // The gone warning must be visually distinct from the ordinary dim "Restore" line AND stay inside - // the sidebar — a warning that reads like normal metadata is one the user scrolls past. - goneTinted: (() => { - const g = document.querySelector('#ck-groups .ck-row-prev .mt.gone'); - const plain = document.querySelector('#ck-groups .ck-row:not(.ck-row-prev) .mt'); - return !!g && getComputedStyle(g).color !== getComputedStyle(plain).color; - })(), - goneInside: (() => { - const g = document.querySelector('#ck-groups .ck-row-prev .mt.gone'); - return !!g && g.getBoundingClientRect().right <= list.right + 1; + detailInside: details.every((n) => n.getBoundingClientRect().right <= list.right + 1), + clipped: [...names, ...details].every((n) => n.scrollWidth >= n.clientWidth), + signals: groups.querySelectorAll('.shell-signal').length, + selected: selectedRow.classList.contains('selected') && selectedRow.getAttribute('aria-current') === 'true', + reused: selectedRow === groups.querySelector('.group-attention .shell-session') && document.activeElement === selectedRow, + semanticGroups: Array.from(groups.querySelectorAll('.shell-group')).every((section) => { + const headingId = section.getAttribute('aria-labelledby'); + return !!headingId && document.getElementById(headingId)?.tagName === 'H2'; + }), + sessionStatusNames: (() => { + const statuses = { + en: ['Awaiting you', 'Working'], ko: ['질문 대기', '작업 중'], + ja: ['確認待ち', '実行中'], zh: ['等待确认', '工作中'], + }[document.documentElement.lang] ?? []; + return Array.from(groups.querySelectorAll('.shell-session')).every((row, index) => row.getAttribute('aria-label')?.includes(statuses[index])); })(), + nestedHidden: getComputedStyle(nested).display === 'none', + mainFillsWrap: Math.abs(main.width - wrap.width) <= 1, }; }); await shot('cockpit-provider-sidebar'); -console.log(`cockpit sidebar: width=${sidebar.sidebarWidth}px twoLines=${sidebar.twoLines} inside=${sidebar.inside} logos=${sidebar.logos} svgLoaded=${sidebar.loaded} actionsHiddenByDefault=${sidebar.actsHidden}`); -console.log(`cockpit summary line: rows=${sidebar.summaries} oneLine=${sidebar.summaryOneLine} inside=${sidebar.summaryInside} clipped=${sidebar.summaryClipped} rowHeight=${sidebar.rowHeight}px`); -if (sidebar.sidebarWidth !== 250 || !sidebar.twoLines || !sidebar.inside || sidebar.logos !== 3 || !sidebar.loaded || !sidebar.actsHidden) { - console.error('QA FAILED — cockpit sidebar geometry / provider marks regressed (expect 250px, 2-line clamp, contained names, 3 loaded SVG marks, hidden row actions).'); - await closeApp(); - process.exit(1); -} -if (sidebar.summaries !== 2 || !sidebar.summaryOneLine || !sidebar.summaryInside || !sidebar.summaryClipped) { - console.error('QA FAILED — session summary line regressed (expect one clipped line per live row, contained in the 250px sidebar).'); +console.log(`unified session sidebar: width=${sidebar.sidebarWidth}px namesInside=${sidebar.inside} detailsInside=${sidebar.detailInside} signals=${sidebar.signals} selected=${sidebar.selected} reused=${sidebar.reused} semanticGroups=${sidebar.semanticGroups} sessionStatusNames=${sidebar.sessionStatusNames} nestedHidden=${sidebar.nestedHidden} terminalFills=${sidebar.mainFillsWrap}`); +if (sidebar.sidebarWidth !== 224 || !sidebar.inside || !sidebar.detailInside || sidebar.signals !== 2 || !sidebar.selected || !sidebar.reused || !sidebar.semanticGroups || !sidebar.sessionStatusNames || !sidebar.nestedHidden || !sidebar.mainFillsWrap) { + console.error('QA FAILED — unified session navigation overflowed or the legacy Cockpit list still consumes terminal width.'); await closeApp(); process.exit(1); } -console.log(`cockpit gone marker: tinted=${sidebar.goneTinted} inside=${sidebar.goneInside}`); -if (!sidebar.goneTinted || !sidebar.goneInside) { - console.error('QA FAILED — the "conversation gone" warning on a restorable row is not visually distinct or overflows the sidebar.'); - await closeApp(); - process.exit(1); -} -// The full-name tooltip must be reachable by KEYBOARD, not only pointer. +// The full session row must be reachable by keyboard in the shared sidebar. const tooltip = await win.evaluate(async () => { - const nm = document.querySelector('#ck-groups .nm'); - nm.focus(); + const row = document.querySelector('#shell-session-groups .shell-session'); + row.focus(); await new Promise((r) => setTimeout(r, 150)); - return { focused: document.activeElement === nm, hasFullName: !!nm.dataset.fullName, labelled: nm.getAttribute('aria-label') === nm.dataset.fullName }; + return { focused: document.activeElement === row, labelled: !!row.getAttribute('aria-label') }; }); await shot('cockpit-provider-tooltip'); -console.log(`cockpit name help: keyboardFocusable=${tooltip.focused} fullName=${tooltip.hasFullName} ariaLabel=${tooltip.labelled}`); -if (!tooltip.focused || !tooltip.hasFullName) { - console.error('QA FAILED — the complete session name is not reachable by keyboard.'); +console.log(`session navigation keyboard: focusable=${tooltip.focused} labelled=${tooltip.labelled}`); +if (!tooltip.focused || !tooltip.labelled) { + console.error('QA FAILED — the unified session row is not keyboard reachable or labelled.'); await closeApp(); process.exit(1); } -await win.evaluate(() => { document.getElementById('ck-groups').innerHTML = ''; }); +} // Usage bar fill — regression guard for the inline-span bug where the fill (width/height // ignored on an inline box) rendered empty. window.devdeck is a frozen contextBridge object @@ -476,7 +758,7 @@ const geometry = () => win.evaluate(() => { return { shell: r('#shell'), content: r('#content'), terms: r('.ck-terms'), xterm: r('.xterm'), footer: r('#usage-bar') }; }); -await showView('cockpit'); +if (cockpitAvailable) await showCockpitViaSession(); const beforeGeo = await geometry(); const modal = await win.evaluate(async (snapshot) => { document.dispatchEvent(new CustomEvent('devdeck:usage-open', { detail: { snapshot } })); diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 076f690..204cb24 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -10,6 +10,21 @@ await mkdir(outDir, { recursive: true }); for (const file of ['index.html', 'styles.css']) { await copyFile(join(srcDir, file), join(outDir, file)); } + +async function copyTree(from, to) { + await mkdir(to, { recursive: true }); + for (const entry of await readdir(from, { withFileTypes: true })) { + const source = join(from, entry.name); + const target = join(to, entry.name); + if (entry.isDirectory()) await copyTree(source, target); + else await copyFile(source, target); + } +} + +for (const dir of ['design', 'shell', 'features']) { + try { await copyTree(join(srcDir, dir), join(outDir, dir)); } + catch (error) { if (error?.code !== 'ENOENT') throw error; } +} await copyFile( join(root, 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), join(outDir, 'xterm.css'), @@ -21,4 +36,8 @@ await mkdir(assetsOut, { recursive: true }); for (const f of await readdir(assetsSrc)) { await copyFile(join(assetsSrc, f), join(assetsOut, f)); } +await copyFile( + join(root, 'node_modules', '@fontsource-variable', 'geist', 'files', 'geist-latin-wght-normal.woff2'), + join(assetsOut, 'geist-latin-wght-normal.woff2'), +); console.log('copied renderer assets to dist/renderer (incl. assets/)'); diff --git a/src/main/store.test.ts b/src/main/store.test.ts index d320c8b..89a7bfd 100644 --- a/src/main/store.test.ts +++ b/src/main/store.test.ts @@ -84,9 +84,9 @@ describe('Store', () => { expect(new Store(file).getOpenAtLogin()).toBe(false); }); - it('persists viewMode and defaults to cards', () => { + it('persists viewMode and defaults to the scan-friendly list', () => { const s = new Store(file); - expect(s.getViewMode()).toBe('cards'); + expect(s.getViewMode()).toBe('list'); s.setViewMode('list'); expect(new Store(file).getViewMode()).toBe('list'); s.setViewMode('cards'); @@ -155,22 +155,34 @@ describe('Store', () => { it('round-trips cockpitSessions (default [])', () => { const s = new Store(file); expect(s.getCockpitSessions()).toEqual([]); - s.setCockpitSessions([{ projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'antigravity', label: 'auth' }]); - expect(new Store(file).getCockpitSessions()).toEqual([{ projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'antigravity', label: 'auth' }]); + s.setCockpitSessions([{ tileId: 'opaque-dev', projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'antigravity', label: 'auth' }]); + expect(new Store(file).getCockpitSessions()).toEqual([{ tileId: 'opaque-dev', projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'antigravity', label: 'auth' }]); + }); + + it('migrates legacy id-less same-project sessions once and persists distinct tile identities', () => { + writeFileSync(file, JSON.stringify({ projects: {}, settings: { cockpitSessions: [ + { projectPath: 'C:/same', name: 'first', sessionId: null, agentId: 'claude' }, + { projectPath: 'C:/same', name: 'second', sessionId: null, agentId: 'claude' }, + ] } })); + + const firstRead = new Store(file).getCockpitSessions(); + const secondRead = new Store(file).getCockpitSessions(); + expect(firstRead.map((entry) => entry.tileId)).toEqual(secondRead.map((entry) => entry.tileId)); + expect(new Set(firstRead.map((entry) => entry.tileId)).size).toBe(2); }); it('round-trips pendingAutoRestore and consume clears it (sanitized)', () => { const s = new Store(file); expect(s.getPendingAutoRestore()).toEqual([]); s.setPendingAutoRestore([ - { projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'claude', label: null }, + { tileId: 'opaque-pending', projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'claude', label: null }, { name: 'no-path' } as never, // junk → dropped by sanitize ]); // survives an app restart (persisted) - expect(new Store(file).getPendingAutoRestore()).toEqual([{ projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'claude', label: null }]); + expect(new Store(file).getPendingAutoRestore()).toEqual([{ tileId: 'opaque-pending', projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'claude', label: null }]); // consume returns the list AND clears it, so a later normal launch won't auto-restore again const s2 = new Store(file); - expect(s2.consumePendingAutoRestore()).toEqual([{ projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'claude', label: null }]); + expect(s2.consumePendingAutoRestore()).toEqual([{ tileId: 'opaque-pending', projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'claude', label: null }]); expect(s2.getPendingAutoRestore()).toEqual([]); expect(new Store(file).getPendingAutoRestore()).toEqual([]); // cleared on disk too }); diff --git a/src/main/store.ts b/src/main/store.ts index fcc35ba..2e76775 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -122,15 +122,31 @@ export class Store { getOpenAtLogin(): boolean { return this.state.settings?.openAtLogin ?? false; } setOpenAtLogin(openAtLogin: boolean): void { this.state.settings = { ...(this.state.settings ?? {}), openAtLogin }; this.save(); } - getViewMode(): 'cards' | 'list' { return this.state.settings?.viewMode === 'list' ? 'list' : 'cards'; } + getViewMode(): 'cards' | 'list' { return this.state.settings?.viewMode === 'cards' ? 'cards' : 'list'; } setViewMode(viewMode: 'cards' | 'list'): void { this.state.settings = { ...(this.state.settings ?? {}), viewMode }; this.save(); } - getCockpitSessions(): PersistedSession[] { return sanitizePersistedList(this.state.settings?.cockpitSessions); } + getCockpitSessions(): PersistedSession[] { + const raw = this.state.settings?.cockpitSessions; + const sessions = sanitizePersistedList(raw); + if (JSON.stringify(raw ?? []) !== JSON.stringify(sessions)) { + this.state.settings = { ...(this.state.settings ?? {}), cockpitSessions: sessions }; + this.save(); + } + return sessions; + } setCockpitSessions(list: PersistedSession[]): void { this.state.settings = { ...(this.state.settings ?? {}), cockpitSessions: sanitizePersistedList(list) }; this.save(); } // The cockpit sessions that were LIVE when the user restarted for an update — auto-restored once on // the next launch (its presence is the "restarted for update" signal), then consumed/cleared. - getPendingAutoRestore(): PersistedSession[] { return sanitizePersistedList(this.state.settings?.pendingAutoRestore); } + getPendingAutoRestore(): PersistedSession[] { + const raw = this.state.settings?.pendingAutoRestore; + const sessions = sanitizePersistedList(raw); + if (JSON.stringify(raw ?? []) !== JSON.stringify(sessions)) { + this.state.settings = { ...(this.state.settings ?? {}), pendingAutoRestore: sessions }; + this.save(); + } + return sessions; + } setPendingAutoRestore(list: PersistedSession[]): void { this.state.settings = { ...(this.state.settings ?? {}), pendingAutoRestore: sanitizePersistedList(list) }; this.save(); } consumePendingAutoRestore(): PersistedSession[] { const l = this.getPendingAutoRestore(); this.setPendingAutoRestore([]); return l; } diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index de03eae..64cb3fc 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -1,35 +1,42 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { SearchAddon } from '@xterm/addon-search'; -import { filterSessions, groupByActivity, needsAttentionCount, numberCollidingNames, cockpitListSignature, shouldNotifyAttention, foldProjectActivity, tileHoldingSession, type CockpitSession } from '../shared/cockpitModel'; +import { filterSessions, groupByActivity, needsAttentionCount, numberCollidingNames, cockpitListSignature, shouldNotifyAttention, foldProjectActivity, sessionNavigationItem, tileHoldingSession, type CockpitSession } from '../shared/cockpitModel'; +import type { ShellSessionInput } from '../shared/shellNavigation'; import { computeActivity, stripAnsi, type ActivityState } from '../shared/sessionStatus'; import { friendlyModel, contextPercent, contextSeverity } from '../shared/sessionMeta'; import { formatDuration } from '../shared/usage'; import { decideKeyAction, selectionCellLength } from '../shared/terminalKeys'; import { unwrapCopiedUrl } from '../shared/urlCopy'; import { findUrlLinks, findFilePathLinks, type BufferRow } from '../shared/linkWrap'; -import { sanitizePersistedList, resolveRestoreTarget, adoptRestorableMatch, type PersistedSession } from '../shared/cockpitPersist'; +import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId, legacyCockpitNavigationId, persistedSessionKey, removeAutoRestoreMatches, sanitizePersistedList, resolveRestoreTarget, adoptRestorableMatch, type PersistedSession } from '../shared/cockpitPersist'; import { toAgentId, type AgentId, type OpenMode, type StaleLevel } from '../shared/types'; import { createProviderLogo, providerName } from './providerLogo'; import { tr, currentLang } from './i18n-runtime'; import { toast } from './loadError'; import { setActiveUsageProvider } from './usageBar'; import { reportShutdownActivity } from './shutdown'; +import { createIcon, type IconName } from './icons'; /** What cockpit:sessionMeta answers with: the log-derived facts plus the ready-made summary line. */ type SessionMetaView = { model: string | null; activeMs: number; contextTokens: number; contextWindow?: number; summary: string | null }; -interface Live { session: CockpitSession; term: Terminal; fit: FitAddon; search: SearchAddon; el: HTMLElement; lastDataAt: number; lastInputAt: number; recentOutput: string; openedSessionId: string | null; openedAt: number; idCheckAt: number; customLabel: string | null; meta: SessionMetaView | null; pinned: boolean; } +interface Live { tileId: string; session: CockpitSession; term: Terminal; fit: FitAddon; search: SearchAddon; el: HTMLElement; lastDataAt: number; lastInputAt: number; recentOutput: string; openedSessionId: string | null; openedAt: number; idCheckAt: number; customLabel: string | null; meta: SessionMetaView | null; pinned: boolean; } /** The renderer's display metadata plus the explicit provider launch intent. */ -export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; } +export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; tileId?: string; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; } const live = new Map(); +const navigationListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); +const sessionSelectionListeners = new Set<(id: string) => void>(); +const sessionsLoadedListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); +let cockpitNavigationCallback: ((id?: string) => void) | null = null; +let lastNavigationSignature = ''; let restorable: PersistedSession[] = []; // previous sessions persisted across restarts, not yet restored let restorableLoaded = false; // guard: don't persist (and clobber the on-disk list) until the initial load resolves /** Saved entries whose conversation is no longer on disk — their row warns BEFORE it is clicked, since * restoring one opens a fresh session under the same name (see resolveRestoreTarget). */ const missingConversations = new Set(); let missingCheckedAt = 0; -const prevKey = (r: PersistedSession): string => `${r.projectPath}\0${r.sessionId ?? ''}`; +const prevKey = (r: PersistedSession): string => persistedSessionKey(r); let liveLabels = new Map(); // live session id -> display label (#N when a project has several sessions) let lastListSig = ''; // signature of the last-rendered session list — renderList() skips a rebuild when nothing visible changed let editingId: string | null = null; // session being inline-renamed (rendered as an in its row, so re-renders keep it) @@ -139,23 +146,21 @@ export function mountCockpit(): void { // then persist once to capture any such session in the correct union. window.devdeck.cockpit.loadSessions() .then(async (list) => { - restorable = sanitizePersistedList(list); restorableLoaded = true; renderList(); if (live.size > 0) persist(); + restorable = sanitizePersistedList(list); restorableLoaded = true; renderList(); publishSessionsLoaded(); if (live.size > 0) persist(); void refreshMissingConversations(); // mark entries whose conversation is gone before they're clicked // Seamless update: if this launch is the relaunch after an update, auto-restore the sessions that // were live at restart (consume clears the marker so a later normal launch won't re-trigger). const pending = await window.devdeck.consumeAutoRestore().catch(() => [] as PersistedSession[]); if (pending.length) await autoRestoreAfterUpdate(pending); }) - .catch(() => { restorableLoaded = true; }); + .catch(() => { restorableLoaded = true; publishSessionsLoaded(); }); } /** After an update relaunch, re-open the sessions that were live — each resolving to its project's * latest conversation (via restoreSession). They're removed from the "Previous" list first so they * aren't shown as restorable AND opened. Sequential to avoid a simultaneous PTY burst. */ async function autoRestoreAfterUpdate(pending: PersistedSession[]): Promise { - const key = (p: PersistedSession): string => `${p.projectPath}\0${p.sessionId ?? ''}`; - const keys = new Set(pending.map(key)); - restorable = restorable.filter((r) => !keys.has(key(r))); + restorable = removeAutoRestoreMatches(restorable, pending); renderList(); for (const entry of pending) await restoreSession(entry); } @@ -233,11 +238,104 @@ async function refreshMissingConversations(): Promise { /** The currently-live sessions in PersistedSession form (for saving / update auto-restore). */ export function liveSessionsForPersist(): PersistedSession[] { - return [...live.values()].map((l) => ({ projectPath: l.session.projectPath, name: l.session.name, sessionId: l.openedSessionId, agentId: l.session.agentId, label: l.customLabel, pinned: l.pinned })); + return [...live.values()].map((l) => ({ tileId: l.tileId, projectPath: l.session.projectPath, name: l.session.name, sessionId: l.openedSessionId, agentId: l.session.agentId, label: l.customLabel, pinned: l.pinned })); } /** How many cockpit sessions are live right now (for the update-restart button label). */ export function liveSessionCount(): number { return live.size; } +function navigationIdentity(liveSession: Live): { tileId: string; projectPath: string; sessionId: string | null; runtimeId: string } { + return { tileId: liveSession.tileId, projectPath: liveSession.session.projectPath, sessionId: liveSession.openedSessionId, runtimeId: liveSession.session.id }; +} + +function navigationIdForLive(liveSession: Live): string { + return cockpitNavigationId(navigationIdentity(liveSession)); +} + +export function cockpitNavigationItems(): ShellSessionInput[] { + const liveItems = [...live.values()]; + const liveConversationIds = new Set(liveItems.map((item) => item.openedSessionId).filter((id): id is string => !!id)); + const previousItems = restorable.filter((item) => !(item.sessionId && liveConversationIds.has(item.sessionId))); + const union = [ + ...liveItems.map((item) => item.customLabel || item.session.name), + ...previousItems.map((item) => item.label || item.name), + ]; + const labels = numberCollidingNames(union); + const current = liveItems.map((item, index) => { + const session = item.session; + const detailBits = [session.branch ?? '—', providerName(session.agentId)]; + const context = contextPercent(item.meta?.contextTokens ?? 0, windowFor(item.meta)); + if (context != null) detailBits.push(`${context}%`); + return sessionNavigationItem({ ...session, id: navigationIdForLive(item) }, labels[index], detailBits.join(' · '), item.pinned); + }); + const previous = previousItems.map((entry, index): ShellSessionInput => ({ + id: cockpitNavigationId(entry), + projectPath: entry.projectPath, + label: labels[liveItems.length + index], + detail: `${providerName(toAgentId(entry.agentId) ?? 'claude')} · ${missingConversations.has(prevKey(entry)) ? tr('cockpit.prev_gone') : tr('cockpit.restore')}`, + activity: 'idle', + pinned: entry.pinned === true, + previous: true, + conversationGone: missingConversations.has(prevKey(entry)), + })); + return [...current, ...previous]; +} + +/** One-release migration map for shell contexts saved before opaque tile identities existed. */ +export function cockpitNavigationAliases(): ReadonlyMap { + const aliases = new Map(); + const ambiguous = new Set(); + const add = (entry: { tileId?: string; projectPath: string; sessionId: string | null; runtimeId?: string }): void => { + const legacy = legacyCockpitNavigationId(entry); + const current = cockpitNavigationId(entry); + if (aliases.has(legacy) && aliases.get(legacy) !== current) { aliases.delete(legacy); ambiguous.add(legacy); } + else if (!ambiguous.has(legacy)) aliases.set(legacy, current); + }; + for (const item of live.values()) add(navigationIdentity(item)); + for (const item of restorable) add(item); + return aliases; +} + +export function onCockpitNavigationChange(listener: (items: readonly ShellSessionInput[]) => void): () => void { + navigationListeners.add(listener); + listener(cockpitNavigationItems()); + return () => navigationListeners.delete(listener); +} + +export function onCockpitSessionsLoaded(listener: (items: readonly ShellSessionInput[]) => void): () => void { + sessionsLoadedListeners.add(listener); + if (restorableLoaded) listener(cockpitNavigationItems()); + return () => sessionsLoadedListeners.delete(listener); +} + +export function onCockpitSessionSelected(listener: (id: string) => void): () => void { + sessionSelectionListeners.add(listener); + return () => sessionSelectionListeners.delete(listener); +} + +export function setCockpitNavigationCallback(callback: (id?: string) => void): void { + cockpitNavigationCallback = callback; +} + +function publishSessionsLoaded(): void { + const items = cockpitNavigationItems(); + for (const listener of sessionsLoadedListeners) listener(items); +} + +function publishCockpitNavigation(): void { + const items = cockpitNavigationItems(); + const signature = JSON.stringify(items); + if (signature === lastNavigationSignature) return; + lastNavigationSignature = signature; + for (const listener of navigationListeners) listener(items); +} + +export function activateCockpitSession(id: string): void { + const current = [...live.values()].find((entry) => navigationIdForLive(entry) === id || legacyCockpitNavigationId(navigationIdentity(entry)) === id); + if (current) { select(current.session.id); return; } + const previous = restorable.find((entry) => cockpitNavigationId(entry) === id || legacyCockpitNavigationId(entry) === id); + if (previous) void restoreSession(previous); +} + /** Per-project live status for the deck's summary + card stripes (no IPC — renderer-shared). */ export function liveProjectActivity(): Map { return foldProjectActivity([...live.values()].map((l) => ({ projectPath: l.session.projectPath, activity: l.session.activity }))); @@ -288,7 +386,7 @@ async function duplicateTileFor(p: OpenReq): Promise { /** Called by Projects "open": switch to the cockpit FIRST (so terminals fit a visible pane), then create a session per project. */ export async function openProjectsInCockpit(projects: OpenReq[]): Promise { - document.querySelector('.rail-item[data-view="cockpit"]')!.click(); + cockpitNavigationCallback?.(); for (const p of projects) { const dup = await duplicateTileFor(p); if (dup) { @@ -403,9 +501,9 @@ async function createSession(p: OpenReq): Promise { }); // Consume the matching restorable entry (dedupe by session id, not path — siblings stay), inheriting // its pin + label when the open request has none (deck/board opens don't know about pins). - const adopted = adoptRestorableMatch(restorable, res.sessionId ?? null, { label: p.label ?? null, pinned: !!p.pinned }); + const adopted = adoptRestorableMatch(restorable, res.sessionId ?? null, { tileId: p.tileId, label: p.label ?? null, pinned: !!p.pinned }); restorable = adopted.rest; - live.set(res.id, { session, term, fit, search, el, lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '', openedSessionId: res.sessionId ?? null, openedAt: Date.now(), idCheckAt: Date.now(), customLabel: adopted.label, meta: null, pinned: adopted.pinned }); + live.set(res.id, { tileId: adopted.tileId ?? createCockpitTileId(), session, term, fit, search, el, lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '', openedSessionId: res.sessionId ?? null, openedAt: Date.now(), idCheckAt: Date.now(), customLabel: adopted.label, meta: null, pinned: adopted.pinned }); select(res.id); updateRailBadge(); persist(); @@ -448,6 +546,8 @@ async function refreshSessionId(id: string): Promise { if (!next || next === l.openedSessionId || !live.has(id)) return; // tile may have closed mid-await l.openedSessionId = next; persist(); // the drifted id is exactly what a quit would have frozen — save the corrected one now + publishCockpitNavigation(); + if (selectedId === id) publishSessionSelection(l); void refreshMeta(id); // model/context % must now read the NEW conversation, not the stale file } @@ -466,6 +566,8 @@ async function refreshProvider(id: string): Promise { l.openedSessionId = null; l.meta = null; persist(); + publishCockpitNavigation(); + if (selectedId === id) publishSessionSelection(l); if (selectedId === id) setActiveUsageProvider(actual); // the footer must follow the tile's REAL provider if (!editingId) renderList(); renderHeader(); @@ -500,6 +602,8 @@ function refreshAllMeta(): void { if (editingId) return; for (const [id, l] of l function select(id: string): void { if (selectedId !== id && findBar && !findBar.classList.contains('hidden')) closeFindBar(); // find decorations belong to the previous session selectedId = id; + const selected = live.get(id); + if (selected) publishSessionSelection(selected); // The always-on usage footer reports the provider of the session you're working in — hand it over // on every selection change (a Claude tile must not be captioned with Codex's percentage). setActiveUsageProvider(live.get(id)?.session.agentId ?? null); @@ -600,6 +704,7 @@ function renderList(): void { const labels = numberCollidingNames(union); liveLabels = new Map(liveLive.map((l, i) => [l.session.id, labels[i]])); const prevLabels = prev.map((_r, i) => labels[liveLive.length + i]); + publishCockpitNavigation(); // Skip the full DOM rebuild when nothing the list shows has changed (this runs on every 1s activity // tick + per-session meta/git refresh, so most calls become no-ops once the deck settles). @@ -610,7 +715,7 @@ function renderList(): void { ctx: contextPercent(l.meta?.contextTokens ?? 0, windowFor(l.meta)), summary: l.meta?.summary ?? null, })), - prev.map((r, i) => ({ key: r.sessionId ?? r.projectPath, label: prevLabels[i], agentId: r.agentId, pinned: r.pinned === true, gone: missingConversations.has(prevKey(r)) })), + prev.map((r, i) => ({ key: r.tileId, label: prevLabels[i], agentId: r.agentId, pinned: r.pinned === true, gone: missingConversations.has(prevKey(r)) })), currentLang(), search, ) + `\nedit:${editingId ?? ''}`; // a row being renamed becomes an — also part of what the list renders if (sig === lastListSig) return; @@ -730,7 +835,8 @@ function row(s: CockpitSession): HTMLElement { else if (a === 'attention') ind.textContent = '❓'; else ind.innerHTML = ''; const nm = el.querySelector('.nm') as HTMLElement; - if (s.id === editingId) { + const legacyListVisible = (document.getElementById('ck-list')?.getClientRects().length ?? 0) > 0; + if (s.id === editingId && legacyListVisible) { nm.replaceChildren(renameInput(s.id, live.get(s.id)?.customLabel ?? s.name)); } else { applyFullName(nm, liveLabels.get(s.id) ?? s.name); @@ -792,12 +898,30 @@ function notifyAttention(l: Live): void { const n = new Notification(name, { body: tr('cockpit.notify_attention'), tag: `devdeck-attn-${l.session.id}` }); n.onclick = () => { void window.devdeck.windowControls.show(); - document.querySelector('.rail-item[data-view="cockpit"]')?.click(); - if (live.has(l.session.id)) select(l.session.id); + const target = cockpitNavigationIdForRuntime([...live.values()].map(navigationIdentity), l.session.id); + if (target) cockpitNavigationCallback?.(target); }; } catch { /* notifications unavailable (rare) — the tray dot still alerts */ } } +export type CockpitPreviousAction = 'pin' | 'unpin' | 'forget'; + +/** Shared-shell previous-row actions route to the same state mutations as the hidden compatibility list. */ +export function manageCockpitPreviousSession(id: string, action: CockpitPreviousAction): void { + const entry = restorable.find((item) => cockpitNavigationId(item) === id || legacyCockpitNavigationId(item) === id); + if (!entry) return; + if (action === 'forget') { forgetSession(entry); return; } + entry.pinned = action === 'pin' ? true : undefined; + persist(); renderList(); +} + +export function restoreAllCockpitSessions(): void { void restoreAll(); } + +function publishSessionSelection(liveSession: Live): void { + const navigationId = navigationIdForLive(liveSession); + for (const listener of sessionSelectionListeners) listener(navigationId); +} + function updateRailBadge(): void { const sessions = [...live.values()].map((l) => l.session); const attention = needsAttentionCount(sessions); // genuine agent questions only @@ -838,10 +962,12 @@ function renderHeader(): void { const s = l.session; const title = document.createElement('span'); title.className = 'title'; const fullName = liveLabels.get(s.id) ?? s.name; - title.textContent = fullName; title.setAttribute('aria-label', fullName); + if (editingId === s.id) title.appendChild(renameInput(s.id, l.customLabel ?? s.name)); + else title.textContent = fullName; + title.setAttribute('aria-label', fullName); title.title = tr('cockpit.rename'); title.addEventListener('dblclick', () => beginRename(s.id)); // edits in the session's list row (single editor, survives re-render) - const branch = document.createElement('span'); branch.className = 'ck-pill'; branch.textContent = `⎇ ${s.branch ?? '-'}${s.dirty > 0 ? ` ✎${s.dirty}` : ''}`; + const branch = document.createElement('span'); branch.className = 'ck-pill'; branch.textContent = `${s.branch ?? '-'}${s.dirty > 0 ? ` · ✎${s.dirty}` : ''}`; // Same mark component as the rows; the localized provider name rides along as alt/title, so identity // never depends on the logo's color alone. const ag = document.createElement('span'); ag.className = 'ck-pill ck-pill-provider'; @@ -850,14 +976,16 @@ function renderHeader(): void { const model = friendlyModel(l.meta?.model ?? null); if (model) { const mp = document.createElement('span'); mp.className = 'ck-pill'; mp.textContent = model; pills.push(mp); } const ctxPct = contextPercent(l.meta?.contextTokens ?? 0, windowFor(l.meta)); - if (ctxPct !== null) { const cp = document.createElement('span'); cp.className = 'ck-pill'; cp.textContent = `🧠 ${ctxPct}%`; cp.title = tr('cockpit.context'); pills.push(cp); } - if (l.meta && l.meta.activeMs > 0) { const tp = document.createElement('span'); tp.className = 'ck-pill'; tp.textContent = `⏱️ ${formatDuration(l.meta.activeMs)}`; pills.push(tp); } + if (ctxPct !== null) { const cp = document.createElement('span'); cp.className = 'ck-pill'; cp.append(createIcon('brain'), `${ctxPct}%`); cp.title = tr('cockpit.context'); pills.push(cp); } + if (l.meta && l.meta.activeMs > 0) { const tp = document.createElement('span'); tp.className = 'ck-pill'; tp.append(createIcon('clock'), formatDuration(l.meta.activeMs)); pills.push(tp); } const sp = document.createElement('span'); sp.className = 'sp'; - const newSession = actBtn('+', tr('cockpit.new_session'), () => void addSessionToCurrentProject()); - const folder = actBtn('📁', tr('cockpit.open_folder'), () => window.devdeck.openFolder(s.projectPath)); - const restart = actBtn('⟳', tr('cockpit.restart'), () => restartSession(s.id)); - const close = actBtn('✕', tr('cockpit.close'), () => void requestClose(s.id)); - headerEl.append(...pills, sp, newSession, folder, restart, close); + const newSession = actBtn('plus', tr('cockpit.new_session'), () => void addSessionToCurrentProject()); + const pin = actBtn('pin', tr(l.pinned ? 'cockpit.unpin' : 'cockpit.pin'), () => togglePin(s.id)); + const rename = actBtn('edit', tr('cockpit.rename'), () => beginRename(s.id)); + const folder = actBtn('folder', tr('cockpit.open_folder'), () => window.devdeck.openFolder(s.projectPath)); + const restart = actBtn('restart', tr('cockpit.restart'), () => restartSession(s.id)); + const close = actBtn('close', tr('cockpit.close'), () => void requestClose(s.id)); + headerEl.append(...pills, sp, newSession, pin, rename, folder, restart, close); } /** "+ New session": spawn another, fresh conversation in the SAME project as the selected session. */ @@ -867,8 +995,8 @@ async function addSessionToCurrentProject(): Promise { await createSession({ path: s.projectPath, name: s.name, staleLevel: s.staleLevel, branch: s.branch, dirty: s.dirty, mode: 'new', agentId: s.agentId }); } -function actBtn(glyph: string, title: string, onClick: () => void): HTMLButtonElement { - const b = document.createElement('button'); b.className = 'ck-act'; b.textContent = glyph; b.title = title; b.addEventListener('click', onClick); return b; +function actBtn(icon: IconName, title: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement('button'); b.className = 'ck-act'; b.appendChild(createIcon(icon)); b.title = title; b.setAttribute('aria-label', title); b.addEventListener('click', onClick); return b; } async function restartSession(id: string): Promise { @@ -932,7 +1060,7 @@ const restoring = new Set(); * opened with, whatever the globally selected agent is now (a Claude conversation must never be * handed to `codex`). Unrecognized legacy ids fall back to the active agent, as they always did. */ async function restoreSession(entry: PersistedSession): Promise { - const key = `${entry.projectPath}\0${entry.sessionId ?? ''}`; + const key = persistedSessionKey(entry); if (restoring.has(key)) return; restoring.add(key); restorable = restorable.filter((r) => r !== entry); @@ -949,7 +1077,7 @@ async function restoreSession(entry: PersistedSession): Promise { let ids: string[] = []; try { ids = await window.devdeck.cockpit.sessionIds(entry.projectPath, owner); } catch { ids = []; } const target = resolveRestoreTarget(entry, ids, liveIds, reserved); - const ok = await createSession({ path: entry.projectPath, name: entry.name, staleLevel: 'neutral', branch: null, dirty: 0, sessionId: target.sessionId, mode: target.fresh ? 'new' : 'auto', label: entry.label ?? null, pinned: entry.pinned, agentId: owner }); + const ok = await createSession({ path: entry.projectPath, name: entry.name, staleLevel: 'neutral', branch: null, dirty: 0, tileId: entry.tileId, sessionId: target.sessionId, mode: target.fresh ? 'new' : 'auto', label: entry.label ?? null, pinned: entry.pinned, agentId: owner }); if (ok) { // Say why the tile is empty — whether its conversation was deleted or was never recorded. // Silence here would read as "my session lost its history". @@ -974,8 +1102,8 @@ function renameSession(id: string, label: string): void { // Editing is RENDER STATE (editingId), not a mutated DOM node: a list rebuild (e.g. row click → select) // would otherwise orphan a captured and the editor would silently never appear. -function beginRename(id: string): void { editingId = id; renderList(); } -function cancelRename(): void { editingId = null; renderList(); } +function beginRename(id: string): void { editingId = id; renderList(); renderHeader(); } +function cancelRename(): void { editingId = null; renderList(); renderHeader(); } function commitRename(id: string, value: string): void { editingId = null; renameSession(id, value); } /** Build the inline rename rendered into the editing row's name slot. */ diff --git a/src/renderer/design/base.css b/src/renderer/design/base.css new file mode 100644 index 0000000..02ef668 --- /dev/null +++ b/src/renderer/design/base.css @@ -0,0 +1,18 @@ +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + background: var(--dd-canvas); + color: var(--dd-text); + font-family: var(--dd-font-sans); + font-size: 13px; + display: flex; + flex-direction: column; + overflow: hidden; +} +button, input, select, textarea { font: inherit; } +:focus-visible { outline: 2px solid var(--dd-focus); outline-offset: 2px; } +.hidden { display: none !important; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/src/renderer/design/components.css b/src/renderer/design/components.css new file mode 100644 index 0000000..c1c4e27 --- /dev/null +++ b/src/renderer/design/components.css @@ -0,0 +1,58 @@ +.ui-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; } + +.shutdown-menu-item { display: flex; align-items: center; gap: 7px; } +.ui-button, .ui-icon-button { + border: 1px solid var(--dd-border-strong); border-radius: var(--dd-radius-sm); + background: var(--dd-surface); color: var(--dd-text); cursor: pointer; + transition: background var(--dd-motion-fast), border-color var(--dd-motion-fast); +} +.ui-button { min-height: 30px; padding: 5px 10px; } +.ui-icon-button { width: 30px; height: 30px; display: inline-grid; place-items: center; padding: 0; } +.ui-button:hover, .ui-icon-button:hover { background: var(--dd-surface-raised); border-color: var(--dd-border-strong); } +.ui-button.primary { background: var(--dd-text); border-color: var(--dd-text); color: var(--dd-canvas); } +.ui-toolbar { display: flex; align-items: center; gap: var(--dd-space-2); } +.ui-status { display: inline-flex; align-items: center; gap: var(--dd-space-2); color: var(--dd-text-muted); } +.ui-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dd-text-faint); } +.ui-status.attention::before { background: var(--dd-attention); } +.ui-status.working::before { background: var(--dd-working); } +.ui-status.success::before { background: var(--dd-success); } +.ui-row { border-bottom: 1px solid var(--dd-border); } +.ui-drawer { background: var(--dd-sidebar); border-left: 1px solid var(--dd-border); } +.ui-dialog { background: var(--dd-sidebar); border: 1px solid var(--dd-border-strong); } + +body .pm-overlay { + align-items: stretch; + justify-content: flex-end; + padding: 0; + background: rgb(0 0 0 / .48); +} + +body .pm-modal.pm-drawer { + width: min(640px, calc(100vw - 52px)); + height: 100%; + max-height: none; + border-block: 0; + border-right: 0; + border-radius: 0; + background: var(--dd-sidebar); + box-shadow: -18px 0 48px rgb(0 0 0 / .45); +} + +body .pm-modal.pm-sheet { + width: 100%; + height: 100%; + max-height: none; + margin: 0; + border: 0; + border-radius: 0; + background: var(--dd-sidebar); + box-shadow: none; +} + +body .pm-close { + display: inline-grid; + width: 30px; + height: 30px; + place-items: center; + padding: 0; +} diff --git a/src/renderer/design/tokens.css b/src/renderer/design/tokens.css new file mode 100644 index 0000000..3f313c0 --- /dev/null +++ b/src/renderer/design/tokens.css @@ -0,0 +1,42 @@ +@font-face { + font-family: "Geist"; + src: url("../assets/geist-latin-wght-normal.woff2") format("woff2"); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} + +:root { + color-scheme: dark; + --dd-canvas: #0a0a0a; + --dd-sidebar: #171717; + --dd-surface: #1d1d1d; + --dd-surface-raised: #242424; + --dd-surface-selected: #2d2d2d; + --dd-border: rgb(255 255 255 / 0.075); + --dd-border-strong: rgb(255 255 255 / 0.14); + --dd-text: #f5f5f5; + --dd-text-muted: #a3a3a3; + --dd-text-faint: #7c7c7c; + --dd-attention: #f0b44d; + --dd-working: #7c9cff; + --dd-success: #52d49a; + --dd-destructive: #ff716a; + --dd-focus: #9ab3ff; + --dd-font-sans: "Geist", "Segoe UI", system-ui, sans-serif; + --dd-font-mono: "Cascadia Mono", "Cascadia Code", Consolas, ui-monospace, monospace; + --dd-fs-meta: 11px; + --dd-fs-body: 12.5px; + --dd-fs-row: 14px; + --dd-fs-title: 16px; + --dd-fs-view: 20px; + --dd-space-1: 4px; + --dd-space-2: 8px; + --dd-space-3: 12px; + --dd-space-4: 16px; + --dd-space-6: 24px; + --dd-radius-sm: 6px; + --dd-radius-md: 8px; + --dd-radius-lg: 10px; + --dd-motion-fast: 120ms; +} diff --git a/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css new file mode 100644 index 0000000..b0de458 --- /dev/null +++ b/src/renderer/features/cockpit/cockpit.css @@ -0,0 +1,45 @@ +/* Session navigation lives in the shared command-center sidebar. The legacy + list remains mounted as an internal compatibility surface while migration + completes, but no longer consumes terminal workspace width. */ +#view-cockpit .ck-list, +#view-cockpit .ck-expand { + display: none !important; +} + +#view-cockpit .ck-wrap, +#view-cockpit .ck-main { + width: 100%; +} + +#view-cockpit .ck-main { + background: var(--dd-canvas); +} + +#view-cockpit .ck-act { + display: inline-grid; + width: 30px; + height: 30px; + place-items: center; + padding: 0; +} + +#view-cockpit .ck-pill { + gap: 5px; +} + +#shell-session-groups .shell-group + .shell-group { + margin-top: 2px; +} + +#shell-session-groups .group-attention .shell-section-label { + color: var(--dd-attention); +} + +#shell-session-groups .group-working .shell-section-label { + color: var(--dd-working); +} + +#shell-session-groups .shell-session:focus-visible { + outline: 2px solid var(--dd-focus); + outline-offset: -2px; +} diff --git a/src/renderer/features/projects/projects.css b/src/renderer/features/projects/projects.css new file mode 100644 index 0000000..dbfaac8 --- /dev/null +++ b/src/renderer/features/projects/projects.css @@ -0,0 +1,167 @@ +#view-projects { + background: var(--dd-canvas); +} + +#view-projects .view-toolbar { + min-height: 56px; + padding: 10px 20px; + border-bottom: 1px solid var(--dd-border); + background: color-mix(in srgb, var(--dd-canvas) 94%, transparent); +} + +#view-projects #new-project, +#view-projects #open-selected { display: inline-flex; align-items: center; gap: 6px; } + +#view-projects .project-display-menu { + top: calc(100% + 6px); + right: auto; + left: 0; + min-width: min(260px, calc(100vw - 40px)); +} + +#view-projects .project-display-menu.align-end { right: 0; left: auto; } + +#view-projects .project-display-menu .menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +#view-projects .project-display-menu .menu-item.active { + color: var(--accent-hover); +} + +#view-projects .display-agent-control { + display: grid; + gap: 5px; + margin: 4px 4px 2px; + padding: 7px 4px 2px; + border-top: 1px solid var(--border); + color: var(--text-dim); + font-size: 11px; +} + +#view-projects .display-agent-control select { min-width: 0; } +#view-projects #agent-select-options { display: grid; gap: 2px; } +#view-projects .display-agent-option { justify-content: flex-start; } + +@media (max-width: 560px) { + #view-projects .view-toolbar { gap: 8px; padding: 8px 12px; } + #view-projects #proj-search { flex: 1 1 180px; min-width: 0; } + #view-projects #proj-sort { flex: 1 1 132px; min-width: 0; } + #view-projects .vt-spacer { display: none; } + #view-projects .deck-pulse { margin-right: 0; } +} + +#view-projects #cards.as-list { + display: block; + max-width: 1440px; + margin: 0 auto; + padding: 12px 20px 32px; +} + +#view-projects .prow { + grid-template-columns: 20px minmax(88px, auto) minmax(150px, .72fr) minmax(220px, 1.5fr) minmax(112px, .55fr) minmax(128px, .65fr) auto; + min-height: 68px; + padding: 10px 12px; + border: 0; + border-bottom: 1px solid var(--dd-border); + border-radius: 0; + background: transparent; + box-shadow: none; + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +#view-projects .prow:hover, +#view-projects .prow:focus-visible { + background: var(--dd-surface-raised); +} + +#view-projects .prow:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--dd-focus); +} + +#view-projects .prow.selected { + background: var(--dd-surface-selected); +} + +#view-projects .prow-name { + font-size: 13px; + font-weight: 650; + color: var(--dd-text); +} + +#view-projects .prow-name small { + display: block; + margin-top: 3px; + font-size: 11px; + font-weight: 450; + color: var(--dd-text-faint); +} + +#view-projects .prow-cue { + font-size: 13px; + font-weight: 520; + color: var(--dd-text-muted); +} + +#view-projects .prow-cue:empty::before { + content: '—'; + color: var(--dd-text-faint); +} + +#view-projects .prow-git, +#view-projects .prow-sess { + font-size: 11px; + color: var(--dd-text-faint); +} + +#view-projects .prow-actions { + gap: 6px; +} + +#view-projects .prow-state { min-width: 0; display: inline-flex; align-items: center; gap: 7px; color: var(--dd-text-muted); font-size: var(--dd-fs-meta); white-space: nowrap; } +#view-projects .prow-state-shape { width: 9px; height: 9px; flex: 0 0 auto; background: var(--dd-text-faint); } +#view-projects .shape-diamond { rotate: 45deg; border-radius: 2px; background: var(--warn); } +#view-projects .shape-ring { border: 2px solid var(--dd-working); border-radius: 50%; background: transparent; } +#view-projects .shape-dot { border-radius: 50%; background: var(--dd-success); } +#view-projects .shape-bar { width: 10px; height: 4px; border-radius: 2px; } +#view-projects .shape-triangle { width: 0; height: 0; border-inline: 5px solid transparent; border-bottom: 9px solid var(--warn); background: transparent; } +#view-projects .shape-square { border-radius: 1px; background: var(--dd-destructive); } +#view-projects .shape-hollow { border: 1px solid var(--dd-text-faint); border-radius: 2px; background: transparent; } + +@media (max-width: 1200px) { + #view-projects .prow { + grid-template-columns: 20px minmax(84px, auto) minmax(130px, .8fr) minmax(180px, 1.3fr) auto; + } + + #view-projects .prow-git, + #view-projects .prow-sess { + display: none; + } +} + +@media (max-width: 600px) { + #view-projects #cards.as-list { padding: 8px 10px 24px; } + #view-projects .prow { + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + "check name" + "state state" + "cue cue" + "git sessions" + "actions actions"; + gap: 7px 10px; + padding: 10px 8px 12px; + } + #view-projects .prow-check { grid-area: check; align-self: start; margin-top: 2px; } + #view-projects .prow-state { grid-area: state; } + #view-projects .prow-name { grid-area: name; min-width: 0; } + #view-projects .prow-cue { grid-area: cue; min-width: 0; } + #view-projects .prow-git { grid-area: git; display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; text-align: left; } + #view-projects .prow-sess { grid-area: sessions; display: inline-flex; min-width: 0; overflow: hidden; justify-content: flex-end; } + #view-projects .prow-actions { grid-area: actions; width: 100%; min-width: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; justify-self: stretch; } + #view-projects .prow-actions .provider-open { flex: 0 0 auto; } +} diff --git a/src/renderer/features/secondary.css b/src/renderer/features/secondary.css new file mode 100644 index 0000000..daf90b3 --- /dev/null +++ b/src/renderer/features/secondary.css @@ -0,0 +1,203 @@ +/* Secondary product surfaces share the command-center density and hierarchy. + These rules intentionally change presentation only; feature modules retain + their existing IPC, filtering, focus, and persistence behavior. */ +#view-next, +#view-usage, +#view-settings { + background: var(--dd-canvas); + color: var(--dd-text); +} + +#view-next .next-head, +#view-usage .usage-scope { + padding: 20px 24px 8px; +} + +#view-next .next-head { + font-size: var(--dd-fs-title); + font-weight: 650; +} + +#view-next .tk-bar, +#view-usage .usage-toolbar { + gap: 8px; + padding: 10px 24px 14px; + border-bottom: 1px solid var(--dd-border); +} + +#view-next .tk-row { + min-height: 50px; + padding: 9px 10px; + border: 0; + border-bottom: 1px solid var(--dd-border); + border-radius: 0; + background: transparent; +} + +#view-next .tk-row:hover, +#view-next .tk-row:focus-within { + background: var(--dd-surface-raised); +} + +#view-next .tk-list { + gap: 0; + padding: 0 24px 8px; +} + +#view-next .tk-group { + margin-left: 24px; + padding: 14px 0 6px 10px; + border-left-width: 2px; +} + +#view-next .tk-del, +#view-next .cal-navbtn { + display: inline-grid; + min-width: 28px; + height: 28px; + place-items: center; + padding: 0; +} + +#view-next .tk-del .ui-icon, +#view-next .cal-navbtn .ui-icon { + width: 14px; + height: 14px; +} + +#view-usage .usage-scope-title { + font-size: var(--dd-fs-view); + font-weight: 650; +} + +#view-usage .usage-scope-note { + max-width: 900px; + color: var(--dd-text-muted); + font-size: var(--dd-fs-body); +} + +#view-usage .usage-cost-cards { + gap: 12px; + padding: 20px 24px 0; +} + +#view-usage .usage-cost-card { + min-height: 108px; + padding: 16px; + border-color: var(--dd-border); + border-radius: var(--dd-radius-md); + background: var(--dd-surface); + box-shadow: none; +} + +#view-usage .usage-cost-card.lead { + border-color: var(--dd-border-strong); + background: var(--dd-surface-raised); +} + +#view-usage .usage-summary, +#view-usage .chart-box { + padding-inline: 24px; +} + +#view-usage .usage-table { + width: calc(100% - 48px); + margin-inline: 24px; +} + +#view-usage .usage-table th, +#view-usage .usage-table td { + height: 38px; + padding-inline: 10px; + border-bottom-color: var(--dd-border); +} + +#settings-form { + width: min(920px, 100%); + max-width: none; + padding: 28px 32px 56px; +} + +#settings-form .set-title { + margin: 0 0 20px; + font-size: var(--dd-fs-view); + font-weight: 650; +} + +#settings-form .set-row { + display: grid; + grid-template-columns: minmax(170px, 220px) minmax(0, 1fr); + gap: 24px; + align-items: start; + margin: 0; + padding: 16px 0; + border-bottom: 1px solid var(--dd-border); +} + +#settings-form .set-label { + padding-top: 7px; + color: var(--dd-text-muted); + font-size: var(--dd-fs-body); + font-weight: 540; +} + +#settings-form .set-input { + width: 100%; + min-width: 0; + min-height: 36px; + border-color: var(--dd-border-strong); + border-radius: var(--dd-radius-sm); + background: var(--dd-surface); +} + +#settings-form .set-num { + min-height: 34px; + border-color: var(--dd-border-strong); + background: var(--dd-surface); +} + +#settings-form .set-hint { + color: var(--dd-text-muted); + opacity: 1; +} + +#settings-form .about { + margin-top: 24px; + padding: 18px; + border: 1px solid var(--dd-border); + border-radius: var(--dd-radius-md); + background: var(--dd-surface); +} + +body .usage-modal-overlay { + background: rgb(0 0 0 / .55); +} + +body .usage-modal { + border-color: var(--dd-border-strong); + border-radius: var(--dd-radius-lg); + background: var(--dd-sidebar); + box-shadow: 0 20px 56px rgb(0 0 0 / .55); +} + +body .um-head { + min-height: 54px; + background: var(--dd-sidebar); + border-bottom-color: var(--dd-border); +} + +body .um-provider { + border-bottom-color: var(--dd-border); +} + +#usage-bar { + border-top-color: var(--dd-border); + background: color-mix(in srgb, var(--dd-canvas) 94%, var(--dd-sidebar)); +} + +@media (max-width: 760px) { + #settings-form { padding: 20px 18px 44px; } + #settings-form .set-row { grid-template-columns: minmax(0, 1fr); gap: 8px; } + #settings-form .set-label { padding-top: 0; } + #view-usage .usage-cost-cards { grid-template-columns: minmax(0, 1fr); } +} diff --git a/src/renderer/icons.test.ts b/src/renderer/icons.test.ts new file mode 100644 index 0000000..2b7289d --- /dev/null +++ b/src/renderer/icons.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { iconMarkup, type IconName } from './icons'; + +describe('iconMarkup', () => { + it('returns local, currentColor SVG markup for every shell icon', () => { + const names: IconName[] = [ + 'search', 'projects', 'sessions', 'tasks', 'usage', 'settings', + 'refresh', 'more', 'play', 'panel-left', 'folder', 'plus', 'restart', + 'close', 'pin', 'edit', 'brain', 'clock', 'trash', 'chevron-left', 'chevron-right', + ]; + + for (const name of names) { + expect(iconMarkup(name)).toContain(' = { + search: '', + projects: '', + sessions: '', + tasks: '', + usage: '', + settings: '', + refresh: '', + more: '', + play: '', + 'panel-left': '', + folder: '', + plus: '', + restart: '', + close: '', + pin: '', + edit: '', + brain: '', + clock: '', + trash: '', + 'chevron-left': '', + 'chevron-right': '', + 'chevron-down': '', + minimize: '', + maximize: '', + restore: '', + moon: '', + power: '', +}; + +export function iconMarkup(name: IconName): string { + return `${paths[name]}`; +} + +export function createIcon(name: IconName, className = 'ui-icon'): SVGSVGElement { + const template = document.createElement('template'); + template.innerHTML = iconMarkup(name); + const icon = template.content.firstElementChild as SVGSVGElement; + icon.classList.add(...className.split(/\s+/).filter(Boolean)); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + return icon; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index b377a3c..8a604ed 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,14 @@ + + + + + + + DevDeck @@ -17,34 +24,58 @@
+ - +
- - - + + +
- +
+ + +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
- -
- - + - - +
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 3cb9596..163bec6 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -1,5 +1,7 @@ { "proj.new": "New project", + "proj.display": "Display", + "proj.show_hidden": "Show hidden projects", "newproj.title": "Create new project", "newproj.location": "Location", "newproj.name": "Name", @@ -24,6 +26,19 @@ "nav.language": "Language", "nav.next": "Next", "nav.cockpit": "Cockpit", + "shell.quick_open": "Quick open", + "shell.needs_you": "Needs you", + "shell.working": "Working", + "shell.projects": "Projects", + "shell.collapse": "Collapse sidebar", + "shell.expand": "Expand sidebar", + "shell.mobile_sessions": "Sessions", + "shell.session_actions": "Session actions", + "shell.status_attention": "Awaiting you", + "shell.status_working": "Working", + "shell.status_turn": "Your turn", + "shell.status_idle": "Idle", + "shell.status_exited": "Exited", "agent.label": "Default agent for new sessions", "agent.claude": "Claude", "agent.antigravity": "Antigravity", @@ -136,6 +151,15 @@ "deck.badge_attn": "Needs you", "deck.badge_work": "Working", "deck.badge_neglect": "Neglected", + "proj.state_attention": "Needs you", + "proj.state_working": "Working", + "proj.state_fresh": "Fresh", + "proj.state_stale": "Stale", + "proj.state_warning": "Warning", + "proj.state_neglected": "Neglected", + "proj.state_no_record": "No record", + "window.maximize": "Maximize", + "window.restore": "Restore", "update.available": "Update available: v{version}", "update.download": "Download", "update.downloading": "Downloading update… {percent}%", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index b2edcce..1e21c0d 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -1,5 +1,7 @@ { "proj.new": "新規プロジェクト", + "proj.display": "表示", + "proj.show_hidden": "非表示のプロジェクトを表示", "newproj.title": "新規プロジェクトを作成", "newproj.location": "場所", "newproj.name": "名前", @@ -24,6 +26,19 @@ "nav.language": "言語", "nav.next": "次に", "nav.cockpit": "コックピット", + "shell.quick_open": "クイックオープン", + "shell.needs_you": "対応が必要", + "shell.working": "作業中", + "shell.projects": "プロジェクト", + "shell.collapse": "サイドバーを折りたたむ", + "shell.expand": "サイドバーを展開", + "shell.mobile_sessions": "セッション", + "shell.session_actions": "セッション操作", + "shell.status_attention": "確認待ち", + "shell.status_working": "実行中", + "shell.status_turn": "あなたの番", + "shell.status_idle": "アイドル", + "shell.status_exited": "終了", "agent.label": "新規セッションの既定エージェント", "agent.claude": "Claude", "agent.antigravity": "Antigravity", @@ -136,6 +151,15 @@ "deck.badge_attn": "応答待ち", "deck.badge_work": "作業中", "deck.badge_neglect": "放置中", + "proj.state_attention": "確認待ち", + "proj.state_working": "実行中", + "proj.state_fresh": "最新", + "proj.state_stale": "古い", + "proj.state_warning": "警告", + "proj.state_neglected": "放置中", + "proj.state_no_record": "記録なし", + "window.maximize": "最大化", + "window.restore": "元に戻す", "update.available": "アップデートあり: v{version}", "update.download": "ダウンロード", "update.downloading": "アップデートをダウンロード中… {percent}%", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 05a5317..8cff345 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -1,5 +1,7 @@ { "proj.new": "새 프로젝트", + "proj.display": "표시", + "proj.show_hidden": "숨긴 프로젝트 표시", "newproj.title": "새 프로젝트 만들기", "newproj.location": "위치", "newproj.name": "이름", @@ -24,6 +26,19 @@ "nav.language": "언어", "nav.next": "다음", "nav.cockpit": "콕핏", + "shell.quick_open": "빠른 열기", + "shell.needs_you": "응답 필요", + "shell.working": "작업 중", + "shell.projects": "프로젝트", + "shell.collapse": "사이드바 접기", + "shell.expand": "사이드바 펼치기", + "shell.mobile_sessions": "세션", + "shell.session_actions": "세션 작업", + "shell.status_attention": "질문 대기", + "shell.status_working": "작업 중", + "shell.status_turn": "내 차례", + "shell.status_idle": "유휴", + "shell.status_exited": "종료", "agent.label": "새 세션 기본 에이전트", "agent.claude": "Claude", "agent.antigravity": "Antigravity", @@ -136,6 +151,15 @@ "deck.badge_attn": "응답 대기", "deck.badge_work": "작업 중", "deck.badge_neglect": "방치됨", + "proj.state_attention": "질문 대기", + "proj.state_working": "작업 중", + "proj.state_fresh": "최신", + "proj.state_stale": "오래됨", + "proj.state_warning": "주의", + "proj.state_neglected": "방치됨", + "proj.state_no_record": "기록 없음", + "window.maximize": "최대화", + "window.restore": "복원", "update.available": "업데이트 있음: v{version}", "update.download": "다운로드", "update.downloading": "업데이트 다운로드 중… {percent}%", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index a4fd0a5..a24680a 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -1,5 +1,7 @@ { "proj.new": "新建项目", + "proj.display": "显示", + "proj.show_hidden": "显示已隐藏项目", "newproj.title": "新建项目", "newproj.location": "位置", "newproj.name": "名称", @@ -24,6 +26,19 @@ "nav.language": "语言", "nav.next": "待办", "nav.cockpit": "控制台", + "shell.quick_open": "快速打开", + "shell.needs_you": "需要处理", + "shell.working": "工作中", + "shell.projects": "项目", + "shell.collapse": "收起侧栏", + "shell.expand": "展开侧栏", + "shell.mobile_sessions": "会话", + "shell.session_actions": "会话操作", + "shell.status_attention": "等待确认", + "shell.status_working": "工作中", + "shell.status_turn": "轮到你", + "shell.status_idle": "空闲", + "shell.status_exited": "已退出", "agent.label": "新会话的默认代理", "agent.claude": "Claude", "agent.antigravity": "Antigravity", @@ -136,6 +151,15 @@ "deck.badge_attn": "等待回复", "deck.badge_work": "工作中", "deck.badge_neglect": "搁置中", + "proj.state_attention": "等待确认", + "proj.state_working": "工作中", + "proj.state_fresh": "最新", + "proj.state_stale": "陈旧", + "proj.state_warning": "警告", + "proj.state_neglected": "搁置中", + "proj.state_no_record": "无记录", + "window.maximize": "最大化", + "window.restore": "还原", "update.available": "有可用更新: v{version}", "update.download": "下载", "update.downloading": "正在下载更新… {percent}%", diff --git a/src/renderer/main.ts b/src/renderer/main.ts index d4f3781..c31e129 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,10 +1,13 @@ -import { mountProjects, renderProjects, reloadProjects } from './projectsView'; +import { currentProjects, focusProject, mountProjects, onProjectsChanged, renderProjects, reloadProjects, syncProjectDisplayAgentChoices } from './projectsView'; import { setCockpitEnabled } from './openRouter'; import { mountNav } from './nav'; +import { mountShell, type ShellController } from './shell'; +import { cockpitIdentityContext, createContextRestoreCoordinator, type ContextRestoreCoordinator } from '../shared/contextRestore'; +import type { ShellSessionInput } from '../shared/shellNavigation'; import { mountUsage, showUsage } from './usageView'; import { mountSettings, showSettings } from './settingsView'; import { mountNext, showNext } from './nextView'; -import { mountCockpit, showCockpit, liveSessionCount, liveSessionsForPersist, refreshLiveSessionIds, setCockpitContextWindow, setCockpitTrayAlert, setCockpitSidebarCollapsed, refreshCockpitSidebar, setCockpitSessionSummary, setCockpitAiSummary } from './cockpitView'; +import { activateCockpitSession, cockpitNavigationAliases, cockpitNavigationItems, manageCockpitPreviousSession, mountCockpit, onCockpitNavigationChange, onCockpitSessionsLoaded, onCockpitSessionSelected, restoreAllCockpitSessions, setCockpitNavigationCallback, showCockpit, liveSessionCount, liveSessionsForPersist, refreshLiveSessionIds, setCockpitContextWindow, setCockpitTrayAlert, setCockpitSidebarCollapsed, refreshCockpitSidebar, setCockpitSessionSummary, setCockpitAiSummary } from './cockpitView'; import { isCockpitAvailable } from '../shared/cockpitModel'; import { setLanguage, tr, currentLang, languageName, SUPPORTED } from './i18n-runtime'; import { toast } from './loadError'; @@ -12,6 +15,23 @@ import { mountUsageBar, refreshUsageBar, rerenderUsageBar } from './usageBar'; import { mountShutdown, refreshShutdownLabels } from './shutdown'; import { initializeAgentSelection, setSelectedAgent } from './agentSelection'; import type { AgentId } from '../shared/types'; +import { createIcon } from './icons'; +import { maximizeActionPresentation } from '../shared/windowControls'; + +let shellController: ShellController | null = null; +let contextRestore: ContextRestoreCoordinator | null = null; +const SHELL_CONTEXT_KEY = 'devdeck:shell-context:v1'; + +function isShellSessionFixture(value: unknown): value is ShellSessionInput[] { + return Array.isArray(value) && value.every((item) => { + if (!item || typeof item !== 'object') return false; + const candidate = item as Partial; + return typeof candidate.id === 'string' && typeof candidate.projectPath === 'string' + && typeof candidate.label === 'string' && typeof candidate.detail === 'string' + && typeof candidate.pinned === 'boolean' + && ['attention', 'working', 'turn', 'idle', 'exited'].includes(candidate.activity ?? ''); + }); +} const toastHost = document.getElementById('toast-host')!; window.devdeck.onError((msg) => { @@ -70,35 +90,62 @@ window.devdeck.onUpdate(renderUpdate); function applyStaticLabels(): void { document.documentElement.lang = currentLang(); - document.querySelector('#open-selected')!.textContent = '▶ ' + tr('app.open_selected'); - document.querySelector('#new-project')!.textContent = '+ ' + tr('proj.new'); + const openSelected = document.querySelector('#open-selected')!; + openSelected.replaceChildren(createIcon('play'), document.createTextNode(tr('app.open_selected'))); + const newProject = document.querySelector('#new-project')!; + newProject.replaceChildren(createIcon('plus'), document.createTextNode(tr('proj.new'))); const refreshBtn = document.querySelector('#refresh')!; + refreshBtn.replaceChildren(createIcon('refresh')); refreshBtn.title = tr('app.refresh'); refreshBtn.setAttribute('aria-label', tr('app.refresh')); - const map: [string, string][] = [['[data-view="projects"]', 'nav.projects'], ['[data-view="usage"]', 'nav.usage'], ['[data-view="settings"]', 'nav.settings'], ['[data-view="next"]', 'nav.next'], ['[data-view="cockpit"]', 'nav.cockpit'], ['#lang-btn', 'nav.language']]; + const map: [string, string][] = [['[data-view="projects"]', 'nav.projects'], ['[data-view="usage"]', 'nav.usage'], ['[data-view="settings"]', 'nav.settings'], ['[data-view="next"]', 'nav.next'], ['#lang-btn', 'nav.language']]; for (const [sel, key] of map) { const el = document.querySelector(sel); if (el) { el.title = tr(key); el.setAttribute('aria-label', tr(key)); } } const agentSel = document.getElementById('agent-select'); - if (agentSel && !agentSel.classList.contains('hidden')) agentSel.setAttribute('aria-label', tr('agent.label')); + const agentLabel = document.getElementById('agent-select-label'); + if (agentSel) agentSel.setAttribute('aria-label', tr('agent.label')); + if (agentLabel) agentLabel.textContent = tr('agent.label'); + syncProjectDisplayAgentChoices(); const chk = document.querySelector('#view-projects .chk'); if (chk?.lastChild) chk.lastChild.textContent = ' ' + tr('proj.neglected_only'); - const showHidden = document.getElementById('show-hidden'); - if (showHidden?.firstChild) showHidden.firstChild.textContent = '🙈 ' + tr('proj.hidden') + ' '; const ckSearch = document.getElementById('ck-search') as HTMLInputElement | null; if (ckSearch) ckSearch.placeholder = tr('cockpit.search'); refreshShutdownLabels(); // 🌙 labels are phase-aware — let shutdown.ts re-derive them in the new language refreshCockpitSidebar(); // collapse/expand titles are state-aware — re-derive in the new language + shellController?.refreshLabels(); + // Session detail text is produced by Cockpit (restore / conversation-gone), not by shell.ts. + // Republish its current models so a live locale switch cannot leave those rows in the old language. + shellController?.setSessionGroups(cockpitNavigationItems()); + renderMaximizeAction(); +} + +let titlebarMaximized = false; +function renderMaximizeAction(): void { + const maxBtn = document.getElementById('win-max'); + if (!maxBtn) return; + const action = maximizeActionPresentation(titlebarMaximized); + const label = tr(action.labelKey); + maxBtn.replaceChildren(createIcon(action.icon)); + maxBtn.title = label; + maxBtn.setAttribute('aria-label', label); } function mountTitlebar(): void { const wc = window.devdeck.windowControls; - document.getElementById('win-min')!.addEventListener('click', () => void wc.minimize()); - document.getElementById('win-close')!.addEventListener('click', () => void wc.close()); + const minBtn = document.getElementById('win-min')!; + const closeBtn = document.getElementById('win-close')!; + minBtn.replaceChildren(createIcon('minimize')); + closeBtn.replaceChildren(createIcon('close')); + minBtn.addEventListener('click', () => void wc.minimize()); + closeBtn.addEventListener('click', () => void wc.close()); const maxBtn = document.getElementById('win-max')!; maxBtn.addEventListener('click', () => void wc.toggleMaximize()); document.querySelector('.tb-drag')!.addEventListener('dblclick', () => void wc.toggleMaximize()); - const setGlyph = (m: boolean) => { maxBtn.textContent = m ? '❐' : '☐'; maxBtn.title = m ? 'Restore' : 'Maximize'; }; - wc.onMaximizeChange(setGlyph); - void wc.isMaximized().then(setGlyph); + const setIcon = (maximized: boolean) => { + titlebarMaximized = maximized; + renderMaximizeAction(); + }; + wc.onMaximizeChange(setIcon); + void wc.isMaximized().then(setIcon); } // Switch the whole UI to `lang`: persist it, swap the active dictionary, then re-render @@ -181,7 +228,6 @@ async function boot(): Promise { initializeAgentSelection(agents, active); const cockpitOn = isCockpitAvailable(settings.platform, settings.ptyAvailable); setCockpitEnabled(cockpitOn); - if (!cockpitOn) document.querySelector('.rail-item[data-view="cockpit"]')?.remove(); applyStaticLabels(); mountProjects(); mountUsage(); @@ -190,20 +236,99 @@ async function boot(): Promise { mountUsageBar(); mountShutdown(settings.platform); if (cockpitOn) { mountCockpit(); setCockpitContextWindow(settings.contextWindow); setCockpitTrayAlert(settings.trayAlert); setCockpitSidebarCollapsed(settings.cockpitSidebarCollapsed); setCockpitSessionSummary(settings.sessionSummary); setCockpitAiSummary(settings.aiSessionSummary); } - mountNav((view) => { if (view === 'usage') showUsage(); if (view === 'settings') showSettings(); if (view === 'next') showNext(); if (view === 'cockpit') showCockpit(); }); + const nav = mountNav((view) => { + if (view === 'usage') showUsage(); if (view === 'settings') showSettings(); if (view === 'next') showNext(); if (view === 'cockpit') showCockpit(); + shellController?.setActiveProject(null); + shellController?.setActiveSession(null); + contextRestore?.cancel(); + if (view !== 'cockpit') localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'view', id: view })); + }); + shellController = mountShell({ + initialCollapsed: settings.cockpitSidebarCollapsed, + showView: nav.show, + activeView: nav.active, + onCollapse: (collapsed) => { setCockpitSidebarCollapsed(collapsed); void window.devdeck.setCockpitSidebar(collapsed); }, + onProject: (path) => { + nav.show('projects'); + shellController?.setActiveProject(path); + focusProject(path); + localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'project', path })); + }, + onSession: (id) => { + if (!cockpitOn) return; + nav.show('cockpit'); + shellController?.setActiveSession(id); + activateCockpitSession(id); + localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'session', id })); + }, + onPreviousAction: (id, action) => manageCockpitPreviousSession(id, action), + onRestoreAll: () => restoreAllCockpitSessions(), + }); + // Renderer-local QA seam: PTYs cannot be spawned in the screenshot harness, so a validated + // fixture reaches the actual mounted controller without exposing IPC or a general debug API. + document.addEventListener('devdeck:qa-shell-sessions', (event) => { + const fixture = (event as CustomEvent).detail; + if (isShellSessionFixture(fixture)) shellController?.setSessionGroups(fixture); + }); + shellController.setCockpitAvailable(cockpitOn); + let savedContext: unknown = null; + try { savedContext = JSON.parse(localStorage.getItem(SHELL_CONTEXT_KEY) ?? 'null'); } catch { localStorage.removeItem(SHELL_CONTEXT_KEY); } + contextRestore = createContextRestoreCoordinator(savedContext, cockpitOn); + const applyRestore = (restored: import('../shared/shellNavigation').ShellContext | null): void => { + if (!restored) return; + if (restored.kind === 'project') { + nav.show('projects'); shellController?.setActiveProject(restored.path); focusProject(restored.path); + } else if (restored.kind === 'session') { + nav.show('cockpit'); shellController?.setActiveSession(restored.id); activateCockpitSession(restored.id); + } else { + nav.show(restored.id); + } + localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify(restored)); + }; + applyRestore(contextRestore.immediate()); + setCockpitNavigationCallback((id) => { + nav.show('cockpit'); + if (id) { shellController?.setActiveSession(id); activateCockpitSession(id); } + }); + onCockpitSessionSelected((id) => { + const context = cockpitIdentityContext(nav.active(), id); + if (!context) return; + shellController?.setActiveSession(context.id); + localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify(context)); + }); + if (cockpitOn) { + onCockpitNavigationChange((items) => { + shellController?.setSessionGroups([...items]); + }); + shellController.setSessionGroups(cockpitNavigationItems()); + onCockpitSessionsLoaded((items) => applyRestore(contextRestore?.sessionsLoaded( + new Set(items.map((item) => item.id)), cockpitNavigationAliases(), + ) ?? null)); + } else { + applyRestore(contextRestore.sessionsLoaded(new Set())); + } + const syncShellProjects = (items: readonly import('../shared/types').ProjectViewModel[]): void => { + shellController?.setProjects(items.filter((item) => !item.hidden).map(({ path, name, branch }) => ({ path, name, branch }))); + applyRestore(contextRestore?.projectsLoaded(new Set(items.map((item) => item.path))) ?? null); + }; + onProjectsChanged(syncShellProjects); + shellController.setProjects(currentProjects().filter((item) => !item.hidden).map(({ path, name, branch }) => ({ path, name, branch }))); const agentSel = document.getElementById('agent-select') as HTMLSelectElement; + const agentControl = document.getElementById('agent-select-control'); if (agents.length > 1) { - agentSel.classList.remove('hidden'); + agentControl?.classList.remove('hidden'); agentSel.replaceChildren(...agents.map((a) => { const o = document.createElement('option'); o.value = a; o.textContent = tr('agent.' + a); o.selected = a === active; return o; })); agentSel.setAttribute('aria-label', tr('agent.label')); agentSel.addEventListener('change', async () => { + syncProjectDisplayAgentChoices(); await window.devdeck.setAgent(agentSel.value); setSelectedAgent(agentSel.value as AgentId); reloadProjects(); }); + syncProjectDisplayAgentChoices(); } mountLangMenu(); diff --git a/src/renderer/nav.ts b/src/renderer/nav.ts index 2a46311..80c6059 100644 --- a/src/renderer/nav.ts +++ b/src/renderer/nav.ts @@ -1,8 +1,13 @@ -export function mountNav(onShow: (view: string) => void): void { +export type ViewId = 'projects' | 'usage' | 'settings' | 'next' | 'cockpit'; + +export function mountNav(onShow: (view: ViewId) => void): { show(view: ViewId): void; active(): ViewId } { const items = Array.from(document.querySelectorAll('.rail-item[data-view]')); const views = new Map(); for (const id of ['projects', 'usage', 'settings', 'next', 'cockpit']) views.set(id, document.getElementById('view-' + id)!); - function show(view: string): void { + let activeView: ViewId = 'projects'; + function show(view: ViewId): void { + if (!views.has(view)) return; + activeView = view; for (const it of items) { const isActive = it.dataset.view === view; it.classList.toggle('active', isActive); @@ -12,5 +17,6 @@ export function mountNav(onShow: (view: string) => void): void { for (const [id, el] of views) el.classList.toggle('active', id === view); onShow(view); } - for (const it of items) it.addEventListener('click', () => show(it.dataset.view!)); + for (const it of items) it.addEventListener('click', () => show(it.dataset.view as ViewId)); + return { show, active: () => activeView }; } diff --git a/src/renderer/newProjectModal.ts b/src/renderer/newProjectModal.ts index 4aa0cc3..e1db5b4 100644 --- a/src/renderer/newProjectModal.ts +++ b/src/renderer/newProjectModal.ts @@ -1,6 +1,7 @@ import { tr } from './i18n-runtime'; import { validateProjectName } from '../shared/projectName'; import type { Folder } from '../shared/types'; +import { createIcon } from './icons'; // Local toast, mirroring the inline pattern in main.ts (#toast-host + auto-remove). function toast(msg: string): void { @@ -46,7 +47,7 @@ export function openNewProjectModal(onCreated: (path: string) => void): void { const locWrap = document.createElement('div'); locWrap.className = 'np-loc'; const select = document.createElement('select'); select.id = 'np-loc-select'; select.className = 'np-input'; const addLoc = document.createElement('button'); addLoc.type = 'button'; addLoc.className = 'iconbtn'; - addLoc.textContent = '📁'; addLoc.title = tr('newproj.add_location'); addLoc.setAttribute('aria-label', tr('newproj.add_location')); + addLoc.appendChild(createIcon('folder')); addLoc.title = tr('newproj.add_location'); addLoc.setAttribute('aria-label', tr('newproj.add_location')); locWrap.append(select, addLoc); locRow.append(locLabel, locWrap); diff --git a/src/renderer/nextView.ts b/src/renderer/nextView.ts index 5705a73..16f93e2 100644 --- a/src/renderer/nextView.ts +++ b/src/renderer/nextView.ts @@ -8,6 +8,7 @@ import { type Todo, type TaskWithProject, type DueBucket, } from '../shared/tasks'; import { createProviderOpenControl } from './providerOpenControl'; +import { createIcon } from './icons'; import { liveProjectProviders } from './cockpitView'; import type { AgentId } from '../shared/types'; @@ -71,7 +72,7 @@ function dueLabel(due: string | null, now: number): string { function taskRow(it: TaskWithProject, now: number): HTMLElement { const { todo, projectPath, projectName } = it; - const row = document.createElement('div'); row.className = 'tk-row' + (todo.done ? ' tk-done' : ''); row.setAttribute('role', 'listitem'); + const row = document.createElement('div'); row.className = 'tk-row ui-row' + (todo.done ? ' tk-done' : ''); row.setAttribute('role', 'listitem'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'tk-check'; cb.checked = todo.done; cb.setAttribute('aria-label', tr('tasks.done')); @@ -87,7 +88,7 @@ function taskRow(it: TaskWithProject, now: number): HTMLElement { due.title = tr('tasks.due'); due.addEventListener('click', () => startDueEdit(due, projectPath, todo)); - const del = document.createElement('button'); del.className = 'tk-del'; del.textContent = '🗑'; del.title = tr('tasks.delete'); + const del = document.createElement('button'); del.className = 'tk-del'; del.appendChild(createIcon('trash')); del.title = tr('tasks.delete'); del.setAttribute('aria-label', tr('tasks.delete')); del.addEventListener('click', () => mutate(projectPath, (ts) => removeTodo(ts, todo.id))); const project = projects.find((p) => p.path === projectPath); @@ -223,7 +224,7 @@ function render(): void { window.devdeck.setTrayCounts({ overdue }); // Single control row: [project▾][new-todo…][date][add] | separator | [filter▾][filter…][show-done](+clear) | [list|calendar] - const bar = document.createElement('div'); bar.className = 'tk-bar'; + const bar = document.createElement('div'); bar.className = 'tk-bar ui-toolbar'; addControls(bar); bar.appendChild(barSeparator()); filterControls(bar); @@ -290,9 +291,9 @@ function renderCalendar(items: TaskWithProject[], now: number): void { const grid = buildMonthGrid(calYear, calMonth, items, today); const nav = document.createElement('div'); nav.className = 'cal-nav'; - const prev = document.createElement('button'); prev.className = 'cal-navbtn'; prev.textContent = '◀'; prev.title = tr('tasks.prev_month'); + const prev = document.createElement('button'); prev.className = 'cal-navbtn'; prev.appendChild(createIcon('chevron-left')); prev.title = tr('tasks.prev_month'); prev.setAttribute('aria-label', tr('tasks.prev_month')); prev.addEventListener('click', () => { if (calMonth === 0) { calMonth = 11; calYear!--; } else calMonth!--; render(); }); - const next = document.createElement('button'); next.className = 'cal-navbtn'; next.textContent = '▶'; next.title = tr('tasks.next_month'); + const next = document.createElement('button'); next.className = 'cal-navbtn'; next.appendChild(createIcon('chevron-right')); next.title = tr('tasks.next_month'); next.setAttribute('aria-label', tr('tasks.next_month')); next.addEventListener('click', () => { if (calMonth === 11) { calMonth = 0; calYear!++; } else calMonth!++; render(); }); const title = document.createElement('span'); title.className = 'cal-title'; title.textContent = new Intl.DateTimeFormat(localeTag(), { year: 'numeric', month: 'long' }).format(new Date(calYear, calMonth, 1)); diff --git a/src/renderer/projectMemoryModal.ts b/src/renderer/projectMemoryModal.ts index 2299bb9..496c482 100644 --- a/src/renderer/projectMemoryModal.ts +++ b/src/renderer/projectMemoryModal.ts @@ -7,8 +7,11 @@ import { openInTerminal } from './openRouter'; import { presetBoardProject } from './nextView'; import { toast } from './loadError'; import { snapshotRows, timelineRows, type MemoryAction } from './projectMemoryPresentation'; +import { memorySurfaceMode } from './projectMemorySurface'; +import { createIcon } from './icons'; let currentOverlay: HTMLElement | null = null; +let closeCurrent: (() => void) | null = null; function when(at: number): string { return new Intl.DateTimeFormat(localeTag(), { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(at)); @@ -20,7 +23,7 @@ function button(label: string, className = 'chip'): HTMLButtonElement { } export function openProjectMemoryModal(project: ProjectViewModel, trigger: HTMLElement): void { - currentOverlay?.remove(); + closeCurrent?.(); const overlay = document.createElement('div'); overlay.className = 'pm-overlay'; const modal = document.createElement('section'); modal.className = 'pm-modal loading'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-labelledby', 'pm-title'); @@ -30,7 +33,7 @@ export function openProjectMemoryModal(project: ProjectViewModel, trigger: HTMLE const subtitle = document.createElement('div'); subtitle.className = 'pm-subtitle'; subtitle.textContent = tr('memory.title'); heading.append(title, subtitle); const refresh = button(tr('memory.refresh'), 'chip pm-refresh'); refresh.setAttribute('aria-label', tr('memory.refresh')); - const closeButton = button('×', 'iconbtn pm-close'); closeButton.setAttribute('aria-label', tr('memory.close')); closeButton.title = tr('memory.close'); + const closeButton = button('', 'iconbtn pm-close'); closeButton.appendChild(createIcon('close')); closeButton.setAttribute('aria-label', tr('memory.close')); closeButton.title = tr('memory.close'); head.append(heading, refresh, closeButton); const body = document.createElement('div'); body.className = 'pm-body'; const loading = document.createElement('div'); loading.className = 'pm-loading'; loading.setAttribute('role', 'status'); loading.textContent = tr('memory.loading'); @@ -47,15 +50,40 @@ export function openProjectMemoryModal(project: ProjectViewModel, trigger: HTMLE foot.append(footHint, open); modal.append(head, body, foot); overlay.appendChild(modal); document.body.appendChild(overlay); currentOverlay = overlay; + const applySurfaceMode = (): void => { + const mode = memorySurfaceMode(window.innerWidth); + overlay.classList.toggle('pm-mode-drawer', mode === 'drawer'); + overlay.classList.toggle('pm-mode-sheet', mode === 'sheet'); + modal.classList.toggle('pm-drawer', mode === 'drawer'); + modal.classList.toggle('pm-sheet', mode === 'sheet'); + modal.dataset.surface = mode; + }; + applySurfaceMode(); + window.addEventListener('resize', applySurfaceMode); + const close = (): void => { if (currentOverlay !== overlay) return; document.removeEventListener('keydown', onKey, true); + window.removeEventListener('resize', applySurfaceMode); overlay.remove(); currentOverlay = null; + closeCurrent = null; trigger.focus(); }; - const focusable = (): HTMLElement[] => Array.from(modal.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')); + closeCurrent = close; + const focusable = (): HTMLElement[] => Array.from( + modal.querySelectorAll('button, [href], [tabindex]:not([tabindex="-1"])'), + ).filter((item) => { + if (item.matches(':disabled') || item.closest('[hidden], [inert], [aria-hidden="true"], .hidden')) return false; + const style = getComputedStyle(item); + return style.display !== 'none' && style.visibility !== 'hidden' && item.getClientRects().length > 0; + }); const onKey = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return; } + if (event.key === 'Escape') { + // A nested disclosure owns the first Escape. This capture listener deliberately lets the + // event continue to the provider control's document listener; a later Escape closes us. + if (modal.querySelector('[aria-haspopup][aria-expanded="true"]')) return; + event.preventDefault(); event.stopPropagation(); close(); return; + } if (event.key !== 'Tab') return; const list = focusable(); if (!list.length) return; const first = list[0], last = list[list.length - 1], active = document.activeElement; diff --git a/src/renderer/projectMemorySurface.test.ts b/src/renderer/projectMemorySurface.test.ts new file mode 100644 index 0000000..dac48d7 --- /dev/null +++ b/src/renderer/projectMemorySurface.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { memorySurfaceMode } from './projectMemorySurface'; + +describe('memorySurfaceMode', () => { + it('uses an overlay drawer on wide layouts and a sheet on narrow layouts', () => { + expect(memorySurfaceMode(1000)).toBe('drawer'); + expect(memorySurfaceMode(720)).toBe('drawer'); + expect(memorySurfaceMode(719)).toBe('sheet'); + }); +}); diff --git a/src/renderer/projectMemorySurface.ts b/src/renderer/projectMemorySurface.ts new file mode 100644 index 0000000..fbaee22 --- /dev/null +++ b/src/renderer/projectMemorySurface.ts @@ -0,0 +1,5 @@ +export type MemorySurfaceMode = 'drawer' | 'sheet'; + +export function memorySurfaceMode(width: number): MemorySurfaceMode { + return width >= 720 ? 'drawer' : 'sheet'; +} diff --git a/src/renderer/projectOverview.test.ts b/src/renderer/projectOverview.test.ts new file mode 100644 index 0000000..2d210c7 --- /dev/null +++ b/src/renderer/projectOverview.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { openSelectedPresentation, projectRowModel, projectStatePresentation } from './projectOverview'; +import type { ProjectViewModel } from '../shared/types'; + +describe('projectRowModel', () => { + it('keeps the resume cue primary and moves cost/providers to secondary metadata', () => { + const project = { + name: 'checkout-api', + branch: 'main', + uncommitted: 3, + ahead: 1, + resumeCue: { kind: 'lastMessage', text: 'review migration output' }, + sessionCount: 2, + agentIds: ['codex'], + } as unknown as ProjectViewModel; + + expect(projectRowModel(project, 'attention', 1.34)).toMatchObject({ + headline: 'checkout-api', + cue: 'review migration output', + state: 'attention', + primaryLabelKey: 'common.open', + }); + expect(projectRowModel(project, 'attention', 1.34).secondary).toContain('~$1.34'); + }); + + it('falls back to a useful quiet-state cue without inventing session content', () => { + const project = { + name: 'docs', + branch: null, + uncommitted: 0, + ahead: null, + resumeCue: null, + sessionCount: 0, + agentIds: [], + stale: { level: 'neutral', ageDays: null }, + } as unknown as ProjectViewModel; + + expect(projectRowModel(project, '', null)).toMatchObject({ + branchLine: '—', + cue: '', + state: 'neutral', + secondary: '', + }); + }); +}); + +describe('openSelectedPresentation', () => { + it('hides the selection action until at least one project is selected', () => { + expect(openSelectedPresentation(0)).toEqual({ hidden: true, disabled: true }); + expect(openSelectedPresentation(1)).toEqual({ hidden: false, disabled: false }); + expect(openSelectedPresentation(3)).toEqual({ hidden: false, disabled: false }); + }); +}); + +describe('projectStatePresentation', () => { + it('gives every row state visible localized text and a non-color shape', () => { + expect(projectStatePresentation('attention', false)).toEqual({ labelKey: 'proj.state_attention', shape: 'diamond' }); + expect(projectStatePresentation('working', false)).toEqual({ labelKey: 'proj.state_working', shape: 'ring' }); + expect(projectStatePresentation('fresh', false)).toEqual({ labelKey: 'proj.state_fresh', shape: 'dot' }); + expect(projectStatePresentation('neutral', false)).toEqual({ labelKey: 'proj.state_stale', shape: 'bar' }); + expect(projectStatePresentation('warn', false)).toEqual({ labelKey: 'proj.state_warning', shape: 'triangle' }); + expect(projectStatePresentation('neglected', false)).toEqual({ labelKey: 'proj.state_neglected', shape: 'square' }); + expect(projectStatePresentation('neglected', true)).toEqual({ labelKey: 'proj.state_no_record', shape: 'hollow' }); + }); +}); diff --git a/src/renderer/projectOverview.ts b/src/renderer/projectOverview.ts new file mode 100644 index 0000000..f0bcdc5 --- /dev/null +++ b/src/renderer/projectOverview.ts @@ -0,0 +1,58 @@ +import type { ProjectViewModel, StaleLevel } from '../shared/types'; + +export type ProjectRowState = '' | 'attention' | 'working' | StaleLevel; + +export interface ProjectRowModel { + headline: string; + branchLine: string; + cue: string; + state: ProjectRowState; + secondary: string; + primaryLabelKey: 'common.open'; +} + +export type ProjectStateShape = 'diamond' | 'ring' | 'dot' | 'bar' | 'triangle' | 'square' | 'hollow'; +export interface ProjectStatePresentation { labelKey: string; shape: ProjectStateShape; } + +export function projectStatePresentation(state: ProjectRowState, noRecord: boolean): ProjectStatePresentation { + if (noRecord) return { labelKey: 'proj.state_no_record', shape: 'hollow' }; + if (state === 'attention') return { labelKey: 'proj.state_attention', shape: 'diamond' }; + if (state === 'working') return { labelKey: 'proj.state_working', shape: 'ring' }; + if (state === 'fresh') return { labelKey: 'proj.state_fresh', shape: 'dot' }; + if (state === 'warn') return { labelKey: 'proj.state_warning', shape: 'triangle' }; + if (state === 'neglected') return { labelKey: 'proj.state_neglected', shape: 'square' }; + return { labelKey: 'proj.state_stale', shape: 'bar' }; +} + +export function openSelectedPresentation(selectionCount: number): { hidden: boolean; disabled: boolean } { + const inactive = selectionCount === 0; + return { hidden: inactive, disabled: inactive }; +} + +/** + * Distils a dense project record into the information hierarchy used by the + * command-center row: resume intent first, operational metadata second. + */ +export function projectRowModel( + project: ProjectViewModel, + live: '' | 'attention' | 'working', + cost: number | null | undefined, +): ProjectRowModel { + const branchBits = [project.branch ?? '—']; + if (project.uncommitted > 0) branchBits.push(`✎${project.uncommitted}`); + if (project.ahead && project.ahead > 0) branchBits.push(`↑${project.ahead}`); + + const secondary: string[] = []; + if (project.agentIds.length) secondary.push(project.agentIds.join(' + ')); + if (project.sessionCount > 0) secondary.push(String(project.sessionCount)); + if (cost != null) secondary.push(`~$${cost.toFixed(2)}`); + + return { + headline: project.name, + branchLine: branchBits.join(' · '), + cue: project.resumeCue?.text ?? '', + state: live || project.stale?.level || '', + secondary: secondary.join(' · '), + primaryLabelKey: 'common.open', + }; +} diff --git a/src/renderer/projectsView.ts b/src/renderer/projectsView.ts index c00bb85..82f89fc 100644 --- a/src/renderer/projectsView.ts +++ b/src/renderer/projectsView.ts @@ -13,6 +13,8 @@ import type { AgentId } from '../shared/types'; import { selectedAgent } from './agentSelection'; import { createProviderOpenControl } from './providerOpenControl'; import { openProjectMemoryModal } from './projectMemoryModal'; +import { openSelectedPresentation, projectRowModel, projectStatePresentation } from './projectOverview'; +import { createIcon } from './icons'; const AUTO_REFRESH_MS = 45_000; @@ -21,6 +23,8 @@ type ProjectViewModel = Awaited>[n const selected = new Set(); const expanded = new Set(); let projects: ProjectViewModel[] = []; +const projectListeners = new Set<(items: readonly ProjectViewModel[]) => void>(); +let pendingFocusPath: string | null = null; let showHidden = false; // Per-project estimated cost, filled asynchronously after the list renders so a // (potentially slow) full token scan never blocks the project list. @@ -36,7 +40,7 @@ let hasRenderedOnce = false; type SortMode = 'activity' | 'uncommitted' | 'name' | 'opened'; let searchQuery = ''; let sortMode: SortMode = 'activity'; -let viewMode: 'cards' | 'list' = 'cards'; +let viewMode: 'cards' | 'list' = 'list'; // Toolbar pulse click-to-filter (⚠ attention / ◉ working / 🔴 neglected): '' = no filter, // otherwise narrows the deck to that state. The three are mutually exclusive. Like `searchQuery`. let deckFilter: '' | 'attention' | 'working' | 'neglected' = ''; @@ -52,6 +56,70 @@ let searchEl: HTMLInputElement | null = null; let sortEl: HTMLSelectElement | null = null; let viewCardsBtn: HTMLButtonElement | null = null; let viewListBtn: HTMLButtonElement | null = null; +let displayBtn: HTMLButtonElement; +let displayMenu: HTMLElement; + +function displayActionItems(): HTMLButtonElement[] { + if (!displayMenu) return []; + return Array.from(displayMenu.querySelectorAll('[role^="menuitem"]')) + .filter((item) => !item.disabled && !item.closest('.hidden') && !item.hidden); +} + +function setDisplayRovingItem(item: HTMLButtonElement | undefined, focus = false): void { + for (const candidate of displayActionItems()) candidate.tabIndex = candidate === item ? 0 : -1; + if (focus) item?.focus(); +} + +function closeDisplayMenu(restoreFocus = false): void { + if (!displayMenu || !displayBtn) return; + displayMenu.classList.add('hidden'); + displayBtn.setAttribute('aria-expanded', 'false'); + if (restoreFocus) displayBtn.focus(); +} + +function openDisplayMenu(): void { + if (!displayMenu || !displayBtn) return; + displayMenu.classList.remove('align-end'); + displayMenu.classList.remove('hidden'); + if (displayMenu.getBoundingClientRect().right > window.innerWidth) displayMenu.classList.add('align-end'); + displayBtn.setAttribute('aria-expanded', 'true'); + const items = displayActionItems(); + const initial = items.find((item) => item.getAttribute('role') === 'menuitemradio' && item.getAttribute('aria-checked') === 'true') ?? items[0]; + setDisplayRovingItem(initial, true); +} + +/** Build accessible provider menu proxies while retaining the native select as the behavior bridge. */ +export function syncProjectDisplayAgentChoices(): void { + const select = document.getElementById('agent-select') as HTMLSelectElement | null; + const host = document.getElementById('agent-select-options'); + if (!select || !host) return; + host.replaceChildren(); + for (const option of Array.from(select.options)) { + const item = document.createElement('button'); + item.type = 'button'; + item.id = `display-agent-${option.value}`; + item.className = 'menu-item display-agent-option'; + item.dataset.agentId = option.value; + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', String(option.value === select.value)); + item.tabIndex = -1; + const label = document.createElement('span'); label.textContent = option.textContent; + item.append(createProviderLogo(option.value as AgentId, 'ck-provider-logo sm'), label); + item.addEventListener('click', () => { + select.value = option.value; + select.dispatchEvent(new Event('change', { bubbles: true })); + for (const proxy of Array.from(host.querySelectorAll('[role="menuitemradio"]'))) { + proxy.setAttribute('aria-checked', String(proxy.dataset.agentId === option.value)); + } + closeDisplayMenu(true); + }); + host.appendChild(item); + } + const current = displayActionItems().find((item) => item.tabIndex === 0) + ?? displayActionItems().find((item) => item.getAttribute('role') === 'menuitemradio' && item.getAttribute('aria-checked') === 'true') + ?? displayActionItems()[0]; + setDisplayRovingItem(current); +} function fmtTime(ms: number | null): string { if (ms == null) return '—'; @@ -197,7 +265,7 @@ function makeSessions(p: ProjectViewModel, render: () => void): HTMLElement { label.textContent = `${fmtTime(p.lastSessionMs)}${p.sessionCount ? ` · ${p.sessionCount} ${tr('proj.sessions')}` : ''}${usdShort(costByPath.get(p.path))}`; head.appendChild(label); if (p.sessionCount > 1) { - const caret = document.createElement('span'); caret.className = 'caret'; caret.textContent = '⌄'; + const caret = document.createElement('span'); caret.className = 'caret'; caret.appendChild(createIcon('chevron-down')); head.appendChild(caret); head.addEventListener('click', () => { expanded.has(p.path) ? expanded.delete(p.path) : expanded.add(p.path); render(); }); } @@ -252,7 +320,7 @@ function githubBtn(p: ProjectViewModel): HTMLButtonElement { } function memoryBtn(p: ProjectViewModel): HTMLButtonElement { - const b = document.createElement('button'); b.className = 'iconbtn project-memory-button'; b.textContent = '◷'; + const b = document.createElement('button'); b.className = 'iconbtn project-memory-button'; b.appendChild(createIcon('clock')); b.title = tr('memory.button'); b.setAttribute('aria-label', `${tr('memory.button')} · ${p.name}`); b.addEventListener('click', () => openProjectMemoryModal(p, b)); return b; @@ -262,7 +330,7 @@ function memoryBtn(p: ProjectViewModel): HTMLButtonElement { function makeMenuWrap(p: ProjectViewModel): HTMLElement { const menuWrap = document.createElement('div'); menuWrap.className = 'menu-wrap'; const menuBtn = document.createElement('button'); - menuBtn.className = 'iconbtn'; menuBtn.textContent = '⋯'; + menuBtn.className = 'iconbtn'; menuBtn.appendChild(createIcon('more')); menuBtn.setAttribute('aria-label', 'more options'); menuBtn.setAttribute('aria-haspopup', 'menu'); menuBtn.setAttribute('aria-expanded', 'false'); @@ -301,6 +369,8 @@ function makeCard(p: ProjectViewModel, render: () => void, live: '' | 'attention const liveCls = live === 'attention' ? ' live-attention' : live === 'working' ? ' live-working' : ''; card.className = 'card lvl-' + p.stale.level + (noRecord ? ' norecord' : '') + (selected.has(p.path) ? ' selected' : '') + liveCls; card.setAttribute('role', 'listitem'); + card.dataset.projectPath = p.path; + card.tabIndex = -1; const headRow = document.createElement('div'); headRow.className = 'card-head'; const title = document.createElement('span'); title.className = 'card-title'; title.textContent = p.name; title.title = p.name; @@ -345,11 +415,11 @@ function makeCard(p: ProjectViewModel, render: () => void, live: '' | 'attention }); const spacer = document.createElement('span'); spacer.className = 'spacer'; const editorBtn = document.createElement('button'); editorBtn.className = 'iconbtn'; - editorBtn.textContent = '{ }'; editorBtn.title = tr('proj.open_editor'); + editorBtn.appendChild(createIcon('edit')); editorBtn.title = tr('proj.open_editor'); editorBtn.setAttribute('aria-label', tr('proj.open_editor')); editorBtn.addEventListener('click', () => window.devdeck.openEditor(p.path)); const folderBtn = document.createElement('button'); folderBtn.className = 'iconbtn'; - folderBtn.textContent = '📁'; folderBtn.title = tr('proj.open_folder'); + folderBtn.appendChild(createIcon('folder')); folderBtn.title = tr('proj.open_folder'); folderBtn.setAttribute('aria-label', tr('proj.open_folder')); folderBtn.addEventListener('click', () => window.devdeck.openFolder(p.path)); // Compact glance strip near the primary action: session count · last activity · est. cost. @@ -379,10 +449,13 @@ function makeCard(p: ProjectViewModel, render: () => void, live: '' | 'attention // scanning many projects fast while still surfacing the same live cockpit status as cards. function makeRow(p: ProjectViewModel, live: '' | 'attention' | 'working' = ''): HTMLElement { const row = document.createElement('div'); + const model = projectRowModel(p, live, costByPath.get(p.path)); const noRecord = isNoRecord(p); const liveCls = live === 'attention' ? ' live-attention' : live === 'working' ? ' live-working' : ''; row.className = 'prow lvl-' + p.stale.level + (noRecord ? ' norecord' : '') + (selected.has(p.path) ? ' selected' : '') + liveCls; row.setAttribute('role', 'listitem'); + row.dataset.projectPath = p.path; + row.tabIndex = -1; const check = document.createElement('input'); check.type = 'checkbox'; check.className = 'prow-check'; check.checked = selected.has(p.path); check.setAttribute('aria-label', 'select'); check.addEventListener('change', () => { @@ -391,29 +464,22 @@ function makeRow(p: ProjectViewModel, live: '' | 'attention' | 'working' = ''): syncOpenBtn(); }); - // Same signal language as the card stripe/badge (Task 1/4 `.sig`): amber = waiting on you, - // accent = actively working, plain = quiet (staleness level still carries via border-left). - const sig = document.createElement('span'); - sig.className = 'sig' + (live === 'attention' ? ' attn' : live === 'working' ? ' work' : ''); - if (live) { - const sigLabel = live === 'attention' ? tr('deck.badge_attn') : tr('deck.badge_work'); - sig.title = sigLabel; - sig.setAttribute('aria-label', sigLabel); - } else { - sig.setAttribute('aria-hidden', 'true'); - } + const stateModel = projectStatePresentation(model.state, noRecord); + const state = document.createElement('span'); + state.className = `prow-state state-${model.state || 'neutral'}`; + const stateShape = document.createElement('span'); stateShape.className = `prow-state-shape shape-${stateModel.shape}`; stateShape.setAttribute('aria-hidden', 'true'); + const stateText = document.createElement('span'); stateText.className = 'prow-state-text'; stateText.textContent = tr(stateModel.labelKey); + state.append(stateShape, stateText); state.title = stateText.textContent; - const name = document.createElement('span'); name.className = 'prow-name'; name.textContent = p.name; name.title = p.name; - if (p.branch) { - const branchEl = document.createElement('small'); branchEl.textContent = p.branch; - name.appendChild(branchEl); - } + const name = document.createElement('span'); name.className = 'prow-name'; name.textContent = model.headline; name.title = model.headline; + const branchEl = document.createElement('small'); branchEl.textContent = model.branchLine; + name.appendChild(branchEl); // Resume cue: only the harvested cue (not the card's session-first-message fallback) — // a list row is a scan surface, not a substitute for opening the card/cockpit. const cue = document.createElement('span'); cue.className = 'prow-cue'; - cue.textContent = p.resumeCue?.text ?? ''; - if (p.resumeCue?.text) cue.title = p.resumeCue.text; + cue.textContent = model.cue; + if (model.cue) cue.title = model.cue; const git = document.createElement('span'); git.className = 'prow-git'; let gitText = ''; @@ -426,7 +492,7 @@ function makeRow(p: ProjectViewModel, live: '' | 'attention' | 'working' = ''): // stat) — show just the session count here rather than inventing a number. const sess = document.createElement('span'); sess.className = 'prow-sess'; if (p.agentIds.length) sess.appendChild(providerMarks(p)); // which agent(s) wrote this project's history - const sessN = document.createElement('span'); sessN.textContent = p.sessionCount ? String(p.sessionCount) : '—'; + const sessN = document.createElement('span'); sessN.textContent = model.secondary || '—'; sess.appendChild(sessN); const actions = document.createElement('span'); actions.className = 'prow-actions'; @@ -436,14 +502,20 @@ function makeRow(p: ProjectViewModel, live: '' | 'attention' | 'working' = ''): const open = providerOpenControl(p, true); open.classList.add('prow-open'); actions.append(memoryBtn(p), open, makeMenuWrap(p)); - row.append(check, sig, name, cue, git, sess, actions); + row.append(check, state, name, cue, git, sess, actions); return row; } -function syncOpenBtn(): void { openBtn.disabled = selected.size === 0; } +function syncOpenBtn(): void { + const presentation = openSelectedPresentation(selected.size); + openBtn.disabled = presentation.disabled; + openBtn.classList.toggle('hidden', presentation.hidden); +} function syncViewToggle(): void { viewCardsBtn?.classList.toggle('active', viewMode === 'cards'); viewListBtn?.classList.toggle('active', viewMode === 'list'); + viewCardsBtn?.setAttribute('aria-checked', String(viewMode === 'cards')); + viewListBtn?.setAttribute('aria-checked', String(viewMode === 'list')); } function setView(mode: 'cards' | 'list'): void { if (viewMode === mode) return; @@ -457,6 +529,7 @@ function setView(mode: 'cards' | 'list'): void { function render(): void { hiddenCountEl.textContent = String(projects.filter((p) => p.hidden).length); showHiddenBtn.classList.toggle('active', showHidden); + showHiddenBtn.setAttribute('aria-checked', String(showHidden)); // Computed once per render: drives the card status stripe/badge, the activity-sort // priority below, and the reconcile signature (so a live status flip forces a rebuild). const act = liveProjectActivity(); @@ -577,6 +650,17 @@ function render(): void { for (const key of remove) cardCache.delete(key); reconcileChildren(cardsEl, orderedEls); + focusPendingProject(); +} + +function focusPendingProject(): void { + if (!pendingFocusPath) return; + const target = Array.from(cardsEl.querySelectorAll('[data-project-path]')) + .find((item) => item.dataset.projectPath === pendingFocusPath); + if (!target) return; + pendingFocusPath = null; + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + target.focus({ preventScroll: true }); } function uiStateFor(p: ProjectViewModel, act: Map): SignatureUiState { @@ -664,7 +748,8 @@ async function reload(): Promise { return; } projects = proj; - viewMode = settings.viewMode === 'list' ? 'list' : 'cards'; + viewMode = settings.viewMode === 'cards' ? 'cards' : 'list'; + for (const listener of projectListeners) listener([...projects]); syncViewToggle(); render(); hasRenderedOnce = true; @@ -689,8 +774,31 @@ async function reload(): Promise { // label and locale-baked card text aren't in the signature, so drop the cache to force it. export function reloadProjects(): void { cardCache.clear(); reload(); } +export function currentProjects(): readonly ProjectViewModel[] { return [...projects]; } + +export function onProjectsChanged(listener: (items: readonly ProjectViewModel[]) => void): () => void { + projectListeners.add(listener); + return () => projectListeners.delete(listener); +} + +export function focusProject(path: string): void { + const project = projects.find((item) => item.path === path); + if (!project) { pendingFocusPath = path; return; } + searchQuery = ''; + if (searchEl) searchEl.value = ''; + deckFilter = ''; + if (project.hidden) showHidden = true; + selected.clear(); + selected.add(path); + pendingFocusPath = path; + cardCache.clear(); + syncOpenBtn(); + renderDeckPulse(lastPulseCost); + render(); +} + function applyProjectLabels(): void { - if (!searchEl || !sortEl) return; + if (!searchEl || !sortEl || !displayBtn) return; searchEl.placeholder = tr('proj.search_ph'); searchEl.setAttribute('aria-label', tr('proj.search_ph')); sortEl.setAttribute('aria-label', tr('proj.sort')); @@ -698,8 +806,15 @@ function applyProjectLabels(): void { sortEl.options[1].text = tr('proj.sort_uncommitted'); sortEl.options[2].text = tr('proj.sort_name'); sortEl.options[3].text = tr('proj.sort_opened'); - if (viewCardsBtn) { viewCardsBtn.title = tr('proj.view_cards'); viewCardsBtn.setAttribute('aria-label', tr('proj.view_cards')); } - if (viewListBtn) { viewListBtn.title = tr('proj.view_list'); viewListBtn.setAttribute('aria-label', tr('proj.view_list')); } + displayBtn.textContent = tr('proj.display'); + displayBtn.setAttribute('aria-label', tr('proj.display')); + if (displayMenu) displayMenu.setAttribute('aria-label', tr('proj.display')); + if (showHiddenBtn.firstChild) showHiddenBtn.firstChild.textContent = tr('proj.show_hidden') + ' '; + if (viewCardsBtn) { viewCardsBtn.textContent = tr('proj.view_cards'); viewCardsBtn.setAttribute('aria-label', tr('proj.view_cards')); } + if (viewListBtn) { viewListBtn.textContent = tr('proj.view_list'); viewListBtn.setAttribute('aria-label', tr('proj.view_list')); } + const select = document.getElementById('agent-select') as HTMLSelectElement | null; + if (select) for (const option of Array.from(select.options)) option.textContent = tr(`agent.${option.value}`); + syncProjectDisplayAgentChoices(); } export function mountProjects(): void { @@ -711,11 +826,38 @@ export function mountProjects(): void { sortEl = document.getElementById('proj-sort') as HTMLSelectElement; viewCardsBtn = document.getElementById('view-cards') as HTMLButtonElement; viewListBtn = document.getElementById('view-list') as HTMLButtonElement; + displayBtn = document.getElementById('project-display') as HTMLButtonElement; + displayMenu = document.getElementById('project-display-menu')!; + + const toggleDisplayMenu = (): void => { + if (displayMenu?.classList.contains('hidden')) openDisplayMenu(); + else closeDisplayMenu(true); + }; document.getElementById('refresh')!.addEventListener('click', reload); - viewCardsBtn.addEventListener('click', () => setView('cards')); - viewListBtn.addEventListener('click', () => setView('list')); - showHiddenBtn.addEventListener('click', () => { showHidden = !showHidden; render(); }); + displayBtn.addEventListener('click', (event) => { event.stopPropagation(); toggleDisplayMenu(); }); + displayBtn.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); toggleDisplayMenu(); + }); + displayMenu.addEventListener('click', (event) => event.stopPropagation()); + displayMenu.addEventListener('keydown', (event) => { + if (event.target instanceof HTMLSelectElement) return; + const items = displayActionItems(); + if (!items.length) return; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + let next = current; + if (event.key === 'ArrowDown') next = (current + 1 + items.length) % items.length; + else if (event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else return; + event.preventDefault(); + setDisplayRovingItem(items[next], true); + }); + viewCardsBtn.addEventListener('click', () => { setView('cards'); closeDisplayMenu(true); }); + viewListBtn.addEventListener('click', () => { setView('list'); closeDisplayMenu(true); }); + showHiddenBtn.addEventListener('click', () => { showHidden = !showHidden; render(); closeDisplayMenu(true); }); openBtn.addEventListener('click', () => { if (selected.size === 0) return; openInTerminal(projects.filter((p) => selected.has(p.path)).map((p) => toOpenReq(p))); @@ -742,13 +884,22 @@ export function mountProjects(): void { })) reload(); }, 15_000); document.addEventListener('click', () => { + closeDisplayMenu(); document.querySelectorAll('.menu:not(.hidden)').forEach((m) => { m.classList.add('hidden'); const trigger = m.previousElementSibling as HTMLButtonElement | null; if (trigger) trigger.setAttribute('aria-expanded', 'false'); }); }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !displayMenu?.classList.contains('hidden')) { + event.preventDefault(); + closeDisplayMenu(true); + } + }); applyProjectLabels(); + syncOpenBtn(); + syncViewToggle(); reload(); } diff --git a/src/renderer/providerOpenControl.ts b/src/renderer/providerOpenControl.ts index bf2e417..eec47e8 100644 --- a/src/renderer/providerOpenControl.ts +++ b/src/renderer/providerOpenControl.ts @@ -3,6 +3,7 @@ import type { AgentId, ProjectOpenIntent } from '../shared/types'; import { installedAgents, selectedAgent, subscribeAgentSelection } from './agentSelection'; import { tr } from './i18n-runtime'; import { createProviderLogo, providerName } from './providerLogo'; +import { createIcon } from './icons'; export interface ProviderOpenControlOptions { path: string; @@ -37,7 +38,7 @@ export function createProviderOpenControl(opts: ProviderOpenControlOptions): HTM const menuButton = document.createElement('button'); menuButton.className = 'primary provider-open-menu-button'; menuButton.type = 'button'; - menuButton.textContent = '▾'; + menuButton.appendChild(createIcon('chevron-down')); menuButton.setAttribute('aria-haspopup', 'menu'); menuButton.setAttribute('aria-expanded', 'false'); menuButton.setAttribute('aria-label', tr('open.choose_provider')); @@ -55,9 +56,10 @@ export function createProviderOpenControl(opts: ProviderOpenControlOptions): HTM const agentId = selectedAgent(); const name = providerName(agentId); primary.replaceChildren(createProviderLogo(agentId, 'provider-open-logo')); + primary.appendChild(createIcon('play')); const label = document.createElement('span'); label.className = 'provider-open-primary-text'; - label.textContent = opts.compact ? '▶' : `▶ ${tr('proj.open')}`; + label.textContent = tr('proj.open'); primary.appendChild(label); primary.setAttribute('aria-label', tr('open.with_provider', { provider: name })); primary.title = tr('open.with_provider', { provider: name }); @@ -110,7 +112,7 @@ export function createProviderOpenControl(opts: ProviderOpenControlOptions): HTM const fresh = document.createElement('button'); fresh.type = 'button'; fresh.className = 'provider-open-new'; fresh.setAttribute('role', 'menuitem'); - fresh.textContent = '+'; fresh.title = `${name} — ${tr('open.new_session')}`; + fresh.appendChild(createIcon('plus')); fresh.title = `${name} — ${tr('open.new_session')}`; fresh.setAttribute('aria-label', fresh.title); fresh.addEventListener('click', () => activate(option.agentId, 'new')); row.append(automatic, fresh); menu.appendChild(row); diff --git a/src/renderer/settingsView.ts b/src/renderer/settingsView.ts index cb94a62..630958d 100644 --- a/src/renderer/settingsView.ts +++ b/src/renderer/settingsView.ts @@ -2,13 +2,14 @@ import { tr, SUPPORTED, languageName, setLanguage as setRuntimeLang } from './i1 import { setCockpitContextWindow, setCockpitTrayAlert, setCockpitSessionSummary, setCockpitAiSummary } from './cockpitView'; import type { Folder } from '../shared/types'; import { IDLE_HOLD_CHOICES } from '../shared/shutdownIdle'; +import { createIcon } from './icons'; let host: HTMLElement; let onChangedCb: () => void = () => {}; let uid = 0; function field(labelKey: string, control: HTMLElement, forEl?: HTMLElement): HTMLElement { - const row = document.createElement('div'); row.className = 'set-row'; + const row = document.createElement('div'); row.className = 'set-row ui-row'; const lab = document.createElement('label'); lab.className = 'set-label'; lab.textContent = tr(labelKey); if (forEl) { if (!forEl.id) forEl.id = `set-f${uid++}`; lab.htmlFor = forEl.id; } row.append(lab, control); return row; @@ -31,7 +32,7 @@ async function render(): Promise { // kind, add it again with the other button — store.addFolder updates the existing entry in place. const kind = document.createElement('span'); kind.className = 'folder-kind'; kind.textContent = tr(f.kind === 'repo' ? 'set.kind_repo' : 'set.kind_root'); - const rm = document.createElement('button'); rm.className = 'folder-rm'; rm.textContent = '✕'; + const rm = document.createElement('button'); rm.className = 'folder-rm'; rm.appendChild(createIcon('close')); rm.setAttribute('aria-label', tr('set.remove_folder')); rm.addEventListener('click', async () => { await window.devdeck.removeFolder(f.path); render(); onChangedCb(); }); row.append(path, kind, rm); return row; diff --git a/src/renderer/shell.ts b/src/renderer/shell.ts new file mode 100644 index 0000000..8657ab6 --- /dev/null +++ b/src/renderer/shell.ts @@ -0,0 +1,423 @@ +import { createIcon, type IconName } from './icons'; +import { tr } from './i18n-runtime'; +import { + buildSessionGroups, + attentionCount, + filterShellItems, + sessionAccessibleLabel, + shellEntityKey, + type ShellGroupKind, + type ShellProjectInput, + type ShellSessionInput, +} from '../shared/shellNavigation'; +import type { ViewId } from './nav'; + +const viewIcons: Record = { + projects: 'projects', usage: 'usage', settings: 'settings', next: 'tasks', cockpit: 'sessions', +}; + +const viewLabels: Record = { + projects: 'nav.projects', usage: 'nav.usage', settings: 'nav.settings', next: 'nav.next', cockpit: 'nav.cockpit', +}; + +const groupLabels: Record = { + attention: 'shell.needs_you', working: 'shell.working', pinned: 'cockpit.grp_pinned', + turn: 'cockpit.grp_turn', quiet: 'cockpit.grp_idle', previous: 'cockpit.prev_sessions', +}; + +export interface ShellController { + showView(view: ViewId): void; + setCockpitAvailable(available: boolean): void; + setSessionGroups(items: ShellSessionInput[]): void; + setProjects(items: ShellProjectInput[]): void; + setActiveProject(path: string | null): void; + setActiveSession(id: string | null): void; + setCollapsed(collapsed: boolean): void; + activeView(): ViewId; + refreshLabels(): void; +} + +export function mountShell(options: { + initialCollapsed: boolean; + showView(view: ViewId): void; + activeView(): ViewId; + onCollapse(collapsed: boolean): void; + onProject(path: string): void; + onSession(id: string): void; + onPreviousAction(id: string, action: 'pin' | 'unpin' | 'forget'): void; + onRestoreAll(): void; +}): ShellController { + const sidebar = document.getElementById('app-sidebar')!; + const collapse = document.getElementById('shell-collapse') as HTMLButtonElement; + const quickOpen = document.getElementById('shell-quick-open') as HTMLInputElement; + const resultHost = document.getElementById('shell-quick-results')!; + const sessionHost = document.getElementById('shell-session-groups')!; + const projectHost = document.getElementById('shell-projects')!; + const restoreAll = document.getElementById('shell-restore-all') as HTMLButtonElement; + const mobileToggle = document.getElementById('shell-mobile-toggle') as HTMLButtonElement; + const mobileToggleLabel = document.getElementById('shell-mobile-toggle-label')!; + const mobileBackdrop = document.getElementById('shell-mobile-backdrop') as HTMLButtonElement; + let sessions: ShellSessionInput[] = []; + let projects: ShellProjectInput[] = []; + let activeEntityKey = ''; + const sessionRows = new Map(); + const sessionWraps = new Map(); + const sessionMenus = new Map(); + const projectRows = new Map(); + const sessionSections = new Map(); + + const markActiveEntity = (key: string | null): void => { + activeEntityKey = key ?? ''; + for (const item of Array.from(sidebar.querySelectorAll('.shell-entity'))) { + const active = item.dataset.shellEntityKey === activeEntityKey; + item.classList.toggle('selected', active); + if (active) item.setAttribute('aria-current', 'true'); else item.removeAttribute('aria-current'); + } + }; + + const updateActiveEntity = (row: HTMLButtonElement, key: string): void => { + activeEntityKey = key; + for (const item of Array.from(sidebar.querySelectorAll('.shell-entity'))) { + const active = item === row; + item.classList.toggle('selected', active); + if (active) item.setAttribute('aria-current', 'true'); else item.removeAttribute('aria-current'); + } + }; + + for (const button of Array.from(document.querySelectorAll('.rail-item[data-view]'))) { + const view = button.dataset.view as ViewId; + button.prepend(createIcon(viewIcons[view])); + } + collapse.prepend(createIcon('panel-left')); + document.querySelector('#shell-quick-wrap .shell-search-icon')?.append(createIcon('search')); + + const setCollapsed = (collapsed: boolean): void => { + sidebar.classList.toggle('collapsed', collapsed); + collapse.setAttribute('aria-expanded', String(!collapsed)); + collapse.title = tr(collapsed ? 'shell.expand' : 'shell.collapse'); + collapse.setAttribute('aria-label', collapse.title); + }; + + const applyEntityState = (row: HTMLButtonElement, key: string): void => { + row.dataset.shellEntityKey = key; + row.classList.toggle('selected', activeEntityKey === key); + if (activeEntityKey === key) row.setAttribute('aria-current', 'true'); else row.removeAttribute('aria-current'); + }; + + const preserveFocusedRow = (focusedKey: string | undefined, rows: Map): void => { + const row = focusedKey ? rows.get(focusedKey) : undefined; + if (row && (document.activeElement === document.body || document.activeElement == null)) row.focus(); + }; + + mobileToggle.querySelector('.shell-mobile-toggle-icon')?.append(createIcon('sessions')); + restoreAll.prepend(createIcon('restart')); + + const closeSessionMenus = (restoreFocus = false): void => { + for (const [key, menu] of sessionMenus) { + if (menu.classList.contains('hidden')) continue; + menu.classList.add('hidden'); + const trigger = sessionWraps.get(key)?.querySelector('.shell-session-actions'); + trigger?.setAttribute('aria-expanded', 'false'); + if (restoreFocus) trigger?.focus(); + } + }; + + const mobileFocusable = (): HTMLElement[] => Array.from(sidebar.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + )).filter((element) => !element.closest('.hidden') && element.getClientRects().length > 0); + const closeMobileDrawer = (restoreFocus = false): void => { + if (!sidebar.classList.contains('mobile-open')) return; + sidebar.classList.remove('mobile-open'); + sidebar.removeAttribute('role'); sidebar.removeAttribute('aria-modal'); + mobileBackdrop.classList.add('hidden'); + mobileToggle.setAttribute('aria-expanded', 'false'); + closeSessionMenus(); + if (restoreFocus) mobileToggle.focus(); + }; + const openMobileDrawer = (): void => { + if (!matchMedia('(max-width: 720px)').matches) return; + sidebar.classList.add('mobile-open'); + sidebar.setAttribute('role', 'dialog'); sidebar.setAttribute('aria-modal', 'true'); + mobileBackdrop.classList.remove('hidden'); + mobileToggle.setAttribute('aria-expanded', 'true'); + requestAnimationFrame(() => (quickOpen.getClientRects().length ? quickOpen : mobileFocusable()[0])?.focus()); + }; + const updateMobileToggle = (): void => { + const waiting = attentionCount(sessions); + const label = waiting > 0 + ? `${tr('shell.needs_you')} · ${waiting}/${sessions.length}` + : `${tr('shell.mobile_sessions')} · ${sessions.length}`; + mobileToggleLabel.textContent = label; + mobileToggle.title = label; mobileToggle.setAttribute('aria-label', label); + mobileToggle.classList.toggle('has-attention', waiting > 0); + }; + + const createSessionRow = (key: string): HTMLButtonElement => { + const wrap = document.createElement('div'); wrap.className = 'shell-session-wrap'; + const row = document.createElement('button'); row.type = 'button'; + row.addEventListener('click', () => { + const id = row.dataset.sessionId; + if (!id) return; + updateActiveEntity(row, key); + closeMobileDrawer(); + options.onSession(id); + }); + const actions = document.createElement('button'); actions.type = 'button'; actions.className = 'shell-session-actions hidden'; + actions.append(createIcon('more')); actions.setAttribute('aria-haspopup', 'menu'); actions.setAttribute('aria-expanded', 'false'); + const menu = document.createElement('div'); menu.className = 'menu shell-session-menu hidden'; menu.setAttribute('role', 'menu'); + const pin = document.createElement('button'); pin.type = 'button'; pin.className = 'menu-item'; pin.setAttribute('role', 'menuitem'); pin.dataset.sessionAction = 'pin'; + const forget = document.createElement('button'); forget.type = 'button'; forget.className = 'menu-item'; forget.setAttribute('role', 'menuitem'); forget.dataset.sessionAction = 'forget'; + pin.addEventListener('click', (event) => { + event.stopPropagation(); + const id = row.dataset.sessionId; if (!id) return; + options.onPreviousAction(id, row.dataset.pinned === 'true' ? 'unpin' : 'pin'); + closeSessionMenus(true); + }); + forget.addEventListener('click', (event) => { + event.stopPropagation(); + const id = row.dataset.sessionId; if (!id) return; + closeSessionMenus(); options.onPreviousAction(id, 'forget'); + }); + actions.addEventListener('click', (event) => { + event.stopPropagation(); + const opening = menu.classList.contains('hidden'); + closeSessionMenus(); + if (opening) { menu.classList.remove('hidden'); actions.setAttribute('aria-expanded', 'true'); } + }); + actions.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowDown') return; + event.preventDefault(); + if (menu.classList.contains('hidden')) actions.click(); + menu.querySelector('[role="menuitem"]')?.focus(); + }); + menu.addEventListener('click', (event) => event.stopPropagation()); + menu.addEventListener('keydown', (event) => { + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')); + const current = items.indexOf(document.activeElement as HTMLButtonElement); + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeSessionMenus(true); } + else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + items[(current + step + items.length) % items.length]?.focus(); + } + }); + menu.append(pin, forget); wrap.append(row, actions, menu); + sessionRows.set(key, row); + sessionWraps.set(key, wrap); + sessionMenus.set(key, menu); + return row; + }; + + const updateSessionRow = (row: HTMLButtonElement, item: ShellSessionInput, key: string): void => { + const wrap = sessionWraps.get(key)!; + row.dataset.sessionId = item.id; + row.dataset.pinned = String(item.pinned); + row.className = `shell-entity shell-session activity-${item.activity}`; + row.setAttribute('aria-label', sessionAccessibleLabel(item, tr(`shell.status_${item.activity}`))); + let signal = row.querySelector('.shell-signal'); + let copy = row.querySelector('.shell-entity-copy'); + if (!signal || !copy) { + signal = document.createElement('span'); signal.className = 'shell-signal'; signal.setAttribute('aria-hidden', 'true'); + copy = document.createElement('span'); copy.className = 'shell-entity-copy'; + copy.append(document.createElement('strong'), document.createElement('small')); + row.replaceChildren(signal, copy); + } + copy.querySelector('strong')!.textContent = item.label; + const detail = copy.querySelector('small')!; + detail.textContent = item.detail; + detail.classList.toggle('shell-session-warning', item.conversationGone === true); + wrap.className = `shell-session-wrap${item.previous ? ' is-previous' : ''}${item.conversationGone ? ' is-gone' : ''}`; + wrap.dataset.previous = String(item.previous === true); + wrap.dataset.conversationGone = String(item.conversationGone === true); + const actions = wrap.querySelector('.shell-session-actions')!; + actions.classList.toggle('hidden', item.previous !== true); + actions.title = tr('shell.session_actions'); actions.setAttribute('aria-label', `${tr('shell.session_actions')}: ${item.label}`); + const pin = wrap.querySelector('[data-session-action="pin"]')!; + pin.replaceChildren(createIcon('pin'), document.createTextNode(tr(item.pinned ? 'cockpit.unpin' : 'cockpit.pin'))); + pin.dataset.sessionAction = 'pin'; + const forget = wrap.querySelector('[data-session-action="forget"]')!; + forget.replaceChildren(createIcon('trash'), document.createTextNode(tr('cockpit.forget'))); + applyEntityState(row, key); + }; + + const renderSessions = (items: ShellSessionInput[]): void => { + const focusedKey = document.activeElement instanceof HTMLButtonElement + ? document.activeElement.dataset.shellEntityKey : undefined; + const nextKeys = new Set(items.map((item) => shellEntityKey('session', item.id))); + for (const [key, row] of sessionRows) { + if (!nextKeys.has(key)) { + sessionWraps.get(key)?.remove(); sessionRows.delete(key); sessionWraps.delete(key); sessionMenus.delete(key); + } + } + for (const group of buildSessionGroups(items)) { + let section = sessionSections.get(group.kind); + if (!section) { + section = document.createElement('section'); section.className = `shell-group group-${group.kind}`; + const heading = document.createElement('h2'); heading.className = 'shell-section-label'; heading.id = `shell-session-${group.kind}`; + section.setAttribute('aria-labelledby', heading.id); section.appendChild(heading); + sessionSections.set(group.kind, section); + } + const heading = section.querySelector('.shell-section-label')!; + heading.textContent = `${tr(groupLabels[group.kind])} · ${group.items.length}`; + for (const item of group.items) { + const key = shellEntityKey('session', item.id); + const row = sessionRows.get(key) ?? createSessionRow(key); + updateSessionRow(row, item, key); + section.appendChild(sessionWraps.get(key)!); + } + sessionHost.appendChild(section); + } + const visibleGroups = new Set(buildSessionGroups(items).map((group) => group.kind)); + for (const [kind, section] of sessionSections) { + if (!visibleGroups.has(kind)) { section.remove(); sessionSections.delete(kind); } + } + const previousCount = items.filter((item) => item.previous).length; + restoreAll.classList.toggle('hidden', previousCount === 0); + restoreAll.disabled = previousCount === 0; + restoreAll.replaceChildren(createIcon('restart'), document.createTextNode(`${tr('cockpit.restore_all')} · ${previousCount}`)); + updateMobileToggle(); + preserveFocusedRow(focusedKey, sessionRows); + }; + + const createProjectRow = (key: string): HTMLButtonElement => { + const row = document.createElement('button'); row.type = 'button'; + row.addEventListener('click', () => { + const path = row.dataset.projectPath; + if (!path) return; + updateActiveEntity(row, key); + closeMobileDrawer(); + options.onProject(path); + }); + projectRows.set(key, row); + return row; + }; + + const updateProjectRow = (row: HTMLButtonElement, item: ShellProjectInput, key: string): void => { + row.dataset.projectPath = item.path; + row.className = 'shell-entity shell-project'; + let copy = row.querySelector('.shell-entity-copy'); + if (!copy) { + copy = document.createElement('span'); copy.className = 'shell-entity-copy'; + copy.append(document.createElement('strong'), document.createElement('small')); + row.replaceChildren(copy); + } + copy.querySelector('strong')!.textContent = item.name; + copy.querySelector('small')!.textContent = item.branch ?? '—'; + applyEntityState(row, key); + }; + + const renderProjects = (items: ShellProjectInput[]): void => { + const focusedKey = document.activeElement instanceof HTMLButtonElement + ? document.activeElement.dataset.shellEntityKey : undefined; + const nextKeys = new Set(items.map((item) => shellEntityKey('project', item.path))); + for (const [key, row] of projectRows) { + if (!nextKeys.has(key)) { row.remove(); projectRows.delete(key); } + } + for (const item of items) { + const key = shellEntityKey('project', item.path); + const row = projectRows.get(key) ?? createProjectRow(key); + updateProjectRow(row, item, key); + projectHost.appendChild(row); + } + preserveFocusedRow(focusedKey, projectRows); + }; + + const applyQuery = (): void => { + const filtered = filterShellItems(quickOpen.value, sessions, projects); + const hasQuery = quickOpen.value.trim().length > 0; + resultHost.classList.toggle('hidden', !hasQuery); + sessionHost.classList.toggle('quick-filtered', hasQuery); + projectHost.classList.toggle('quick-filtered', hasQuery); + if (!hasQuery) { resultHost.replaceChildren(); return; } + resultHost.replaceChildren(); + const activateSession = (item: ShellSessionInput): void => { + markActiveEntity(shellEntityKey('session', item.id)); + closeMobileDrawer(); + options.onSession(item.id); + quickOpen.value = ''; applyQuery(); + }; + const activateProject = (item: ShellProjectInput): void => { + markActiveEntity(shellEntityKey('project', item.path)); + closeMobileDrawer(); + options.onProject(item.path); + quickOpen.value = ''; applyQuery(); + }; + for (const item of buildSessionGroups(filtered.sessions).flatMap((group) => group.items)) { + const button = document.createElement('button'); button.type = 'button'; button.className = 'shell-quick-result'; + button.textContent = `${item.label} — ${item.detail}`; button.addEventListener('click', () => activateSession(item)); resultHost.appendChild(button); + } + for (const item of filtered.projects) { + const button = document.createElement('button'); button.type = 'button'; button.className = 'shell-quick-result'; + button.textContent = `${item.name} — ${item.branch ?? '—'}`; button.addEventListener('click', () => activateProject(item)); resultHost.appendChild(button); + } + }; + + quickOpen.addEventListener('input', applyQuery); + quickOpen.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { quickOpen.value = ''; applyQuery(); } + else if (event.key === 'Enter') (resultHost.querySelector('button'))?.click(); + }); + restoreAll.addEventListener('click', () => { closeMobileDrawer(); options.onRestoreAll(); }); + mobileToggle.addEventListener('click', () => { + if (sidebar.classList.contains('mobile-open')) closeMobileDrawer(true); else openMobileDrawer(); + }); + mobileBackdrop.addEventListener('click', () => closeMobileDrawer(true)); + sidebar.addEventListener('keydown', (event) => { + if (!sidebar.classList.contains('mobile-open') || event.key !== 'Tab') return; + const focusable = mobileFocusable(); if (!focusable.length) return; + const first = focusable[0], last = focusable.at(-1)!; + if (event.shiftKey && (document.activeElement === first || !sidebar.contains(document.activeElement))) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }); + for (const button of Array.from(document.querySelectorAll('.rail-item[data-view]'))) { + button.addEventListener('click', () => closeMobileDrawer()); + } + document.addEventListener('click', () => closeSessionMenus()); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !sidebar.classList.contains('mobile-open')) return; + event.preventDefault(); closeMobileDrawer(true); + }); + window.addEventListener('resize', () => { + if (!matchMedia('(max-width: 720px)').matches) closeMobileDrawer(); + }); + collapse.addEventListener('click', () => { + const next = !sidebar.classList.contains('collapsed'); setCollapsed(next); options.onCollapse(next); + }); + setCollapsed(options.initialCollapsed); + + const refreshLabels = (): void => { + quickOpen.placeholder = tr('shell.quick_open'); quickOpen.setAttribute('aria-label', tr('shell.quick_open')); + for (const button of Array.from(document.querySelectorAll('.rail-item[data-view]'))) { + const view = button.dataset.view as ViewId; + const label = tr(viewLabels[view]); + const text = button.querySelector('.rail-label'); if (text) text.textContent = label; + button.title = label; button.setAttribute('aria-label', label); + } + document.getElementById('shell-projects-label')!.textContent = tr('shell.projects'); + document.getElementById('shell-projects-label')!.setAttribute('role', 'heading'); + document.getElementById('shell-projects-label')!.setAttribute('aria-level', '2'); + document.getElementById('shell-project-section')!.setAttribute('aria-labelledby', 'shell-projects-label'); + mobileBackdrop.setAttribute('aria-label', tr('shell.collapse')); + setCollapsed(sidebar.classList.contains('collapsed')); + renderSessions(sessions); + renderProjects(projects); + }; + refreshLabels(); + + return { + showView: options.showView, + setCockpitAvailable: (available) => { + document.getElementById('shell-session-section')!.classList.toggle('hidden', !available); + mobileToggle.classList.toggle('hidden', !available); + if (!available) closeMobileDrawer(); + document.querySelector('.rail-item[data-view="cockpit"]')?.classList.toggle('hidden', !available); + }, + setSessionGroups: (items) => { sessions = [...items]; renderSessions(sessions); applyQuery(); }, + setProjects: (items) => { projects = [...items]; renderProjects(projects); applyQuery(); }, + setActiveProject: (path) => { markActiveEntity(path == null ? null : shellEntityKey('project', path)); }, + setActiveSession: (id) => { markActiveEntity(id == null ? null : shellEntityKey('session', id)); }, + setCollapsed, + activeView: options.activeView, + refreshLabels, + }; +} diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css new file mode 100644 index 0000000..7e0e8bd --- /dev/null +++ b/src/renderer/shell/shell.css @@ -0,0 +1,92 @@ +#shell { position: relative; display: flex; flex: 1; min-height: 0; } + +.shell-mobile-toggle { display: none; align-items: center; gap: 6px; max-width: 150px; min-width: 0; height: 28px; padding: 0 8px; border: 1px solid var(--dd-border); border-radius: var(--dd-radius-sm); background: var(--dd-surface); color: var(--dd-text-muted); font-size: var(--dd-fs-meta); } +.shell-mobile-toggle-icon { flex: 0 0 auto; } +#shell-mobile-toggle-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.shell-mobile-toggle.has-attention { border-color: color-mix(in srgb, var(--dd-attention) 65%, var(--dd-border)); color: var(--dd-text); } +.shell-mobile-backdrop { position: absolute; inset: 0; z-index: 79; border: 0; background: rgb(0 0 0 / .52); } + +#app-sidebar { + width: 224px; flex: 0 0 224px; min-width: 0; + display: flex; flex-direction: column; gap: 4px; + padding: 8px; overflow: hidden; + background: var(--dd-sidebar); border-right: 1px solid var(--dd-border); + transition: width var(--dd-motion-fast), flex-basis var(--dd-motion-fast); +} +#app-sidebar.collapsed { width: 52px; flex-basis: 52px; align-items: center; } +.shell-side-head { display: flex; align-items: center; gap: 8px; min-height: 30px; } +.shell-side-brand { flex: 1; font-size: var(--dd-fs-title); font-weight: 650; overflow: hidden; white-space: nowrap; } +#shell-collapse { flex: 0 0 auto; } +#app-sidebar.collapsed .shell-side-brand, +#app-sidebar.collapsed .rail-label, +#app-sidebar.collapsed #shell-quick-wrap, +#app-sidebar.collapsed .shell-scroll, +#app-sidebar.collapsed .shell-section-label, +#app-sidebar.collapsed .lang-wrap { display: none; } +.shell-nav { display: flex; flex-direction: column; gap: 2px; } +.rail-item { + width: 100%; height: 34px; display: flex; align-items: center; justify-content: flex-start; gap: 9px; + padding: 0 9px; border: 1px solid transparent; border-radius: var(--dd-radius-sm); + background: transparent; color: var(--dd-text-muted); cursor: pointer; +} +.rail-item:hover { background: var(--dd-surface-raised); color: var(--dd-text); } +.rail-item.active { background: var(--dd-surface-selected); color: var(--dd-text); border-color: var(--dd-border); box-shadow: none; } +#app-sidebar.collapsed .rail-item { width: 36px; justify-content: center; padding: 0; } +.shell-nav-spacer { flex: 1; } +#shell-quick-wrap { position: relative; margin: 4px 0; } +.shell-search-icon { position: absolute; left: 9px; top: 50%; translate: 0 -50%; color: var(--dd-text-faint); pointer-events: none; } +#shell-quick-open { + width: 100%; height: 32px; padding: 0 9px 0 32px; + border: 1px solid var(--dd-border); border-radius: var(--dd-radius-sm); + background: var(--dd-canvas); color: var(--dd-text); +} +.shell-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; } +.shell-section-label { margin: 0; padding: 12px 8px 5px; color: var(--dd-text-muted); font-size: var(--dd-fs-meta); font-weight: inherit; text-transform: uppercase; letter-spacing: .07em; } +.shell-entity, .shell-quick-result { + width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; + border: 0; border-radius: var(--dd-radius-sm); background: transparent; color: var(--dd-text); cursor: pointer; +} +.shell-entity { padding: 7px 8px; } +.shell-entity:hover, .shell-quick-result:hover { background: var(--dd-surface-raised); } +.shell-entity.selected { background: var(--dd-surface-selected); box-shadow: inset 2px 0 0 var(--dd-working); } +.shell-entity-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.shell-entity-copy strong, .shell-entity-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.shell-entity-copy strong { font-weight: 550; } +.shell-entity-copy small { color: var(--dd-text-muted); font-size: var(--dd-fs-meta); } +.shell-signal { width: 7px; height: 7px; border-radius: 50%; background: var(--dd-text-faint); flex: 0 0 auto; } +.activity-attention .shell-signal { background: var(--dd-attention); } +.activity-working .shell-signal { background: var(--dd-working); } +.shell-session-wrap { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.shell-session-wrap .shell-session { grid-column: 1 / -1; min-width: 0; } +.shell-session-wrap.is-previous .shell-session { grid-column: 1; } +.shell-session-actions { width: 28px; height: 28px; display: inline-grid; place-items: center; padding: 0; border: 0; border-radius: var(--dd-radius-sm); background: transparent; color: var(--dd-text-muted); } +.shell-session-actions:hover, .shell-session-actions:focus-visible { background: var(--dd-surface-raised); color: var(--dd-text); } +.shell-session-menu { top: calc(100% - 2px); right: 2px; min-width: 150px; z-index: 90; } +.shell-session-menu .menu-item { display: flex; align-items: center; gap: 8px; } +.shell-session-warning { color: var(--warn) !important; } +.shell-session-wrap.is-gone .shell-signal { width: 8px; height: 8px; border-radius: 2px; background: var(--warn); rotate: 45deg; } +.shell-restore-all { width: calc(100% - 8px); min-height: 28px; margin: 6px 4px 0; display: flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--dd-border); border-radius: var(--dd-radius-sm); background: transparent; color: var(--dd-text-muted); font-size: var(--dd-fs-meta); } +.shell-restore-all:hover, .shell-restore-all:focus-visible { background: var(--dd-surface-raised); color: var(--dd-text); } +.shell-quick-result { padding: 8px; color: var(--dd-text-muted); } +.quick-filtered { display: none; } +.shell-footer { display: flex; flex-direction: column; gap: 2px; } +#content { flex: 1; min-width: 0; overflow-y: auto; } + +@media (max-width: 720px) { + .shell-mobile-toggle { display: inline-flex; } + #app-sidebar, #app-sidebar.collapsed { + position: absolute; inset: 0 auto 0 0; z-index: 80; + width: min(320px, calc(100% - 32px)); max-width: none; flex: none; align-items: stretch; + box-shadow: 18px 0 46px rgb(0 0 0 / .4); transform: translateX(-105%); + transition: transform var(--dd-motion-fast); + } + #app-sidebar.mobile-open { transform: translateX(0); } + #app-sidebar.mobile-open .shell-side-brand { display: block; } + #app-sidebar.mobile-open .rail-label { display: inline; } + #app-sidebar.mobile-open #shell-quick-wrap, + #app-sidebar.mobile-open .shell-scroll, + #app-sidebar.mobile-open .shell-section-label, + #app-sidebar.mobile-open .lang-wrap { display: block; } + #app-sidebar.mobile-open .rail-item { width: 100%; justify-content: flex-start; padding: 0 9px; } + #shell-collapse { display: none; } +} diff --git a/src/renderer/shutdown.ts b/src/renderer/shutdown.ts index 7ca9e7e..62bd196 100644 --- a/src/renderer/shutdown.ts +++ b/src/renderer/shutdown.ts @@ -1,6 +1,7 @@ import { tr } from './i18n-runtime'; import { toast } from './loadError'; import type { ShutdownStatus } from '../main/shutdownScheduler'; +import { createIcon, type IconName } from './icons'; // 🌙 one-shot idle shutdown: topbar menu (arm toggle / shut down now), a countdown banner with the // only cancel path, and the next-boot verification banner ("did it REALLY shut down last night?"). @@ -79,25 +80,26 @@ function buildMenu(): void { menuEl.setAttribute('role', 'menu'); wrap.appendChild(menuEl); - const item = (label: string, onClick: () => void): HTMLButtonElement => { + const item = (label: string, icon: IconName, onClick: () => void): HTMLButtonElement => { const b = document.createElement('button'); - b.className = 'menu-item'; b.setAttribute('role', 'menuitem'); b.textContent = label; + b.className = 'menu-item shutdown-menu-item'; b.setAttribute('role', 'menuitem'); + b.append(createIcon(icon), document.createTextNode(label)); b.addEventListener('click', () => { close(); onClick(); }); return b; }; const render = (): void => { menuEl.replaceChildren(); if (status.phase === 'countdown') { - menuEl.appendChild(item('⏻ ' + tr('shutdown.cancel'), async () => applyStatus(await window.devdeck.shutdown.cancel()))); + menuEl.appendChild(item(tr('shutdown.cancel'), 'power', async () => applyStatus(await window.devdeck.shutdown.cancel()))); return; } - const toggleLabel = (status.phase === 'armed' ? '✓ ' : '') + '🌙 ' + tr('shutdown.arm'); - menuEl.appendChild(item(toggleLabel, async () => { + const toggleLabel = (status.phase === 'armed' ? '✓ ' : '') + tr('shutdown.arm'); + menuEl.appendChild(item(toggleLabel, 'moon', async () => { if (status.phase === 'armed') { applyStatus(await window.devdeck.shutdown.disarm()); return; } applyStatus(await window.devdeck.shutdown.arm()); toast(tr('shutdown.arm_warn')); // one-shot force-close warning, non-blocking })); - menuEl.appendChild(item('⏻ ' + tr('shutdown.now'), async () => applyStatus(await window.devdeck.shutdown.now()))); + menuEl.appendChild(item(tr('shutdown.now'), 'power', async () => applyStatus(await window.devdeck.shutdown.now()))); }; const open = (): void => { render(); menuEl.classList.remove('hidden'); btnEl.setAttribute('aria-expanded', 'true'); }; const close = (): void => { menuEl.classList.add('hidden'); btnEl.setAttribute('aria-expanded', 'false'); }; @@ -127,6 +129,7 @@ export function mountShutdown(platform: string): void { if (platform !== 'win32') return; // feature (and shutdown.exe semantics) are Windows-only bannerEl = document.getElementById('shutdown-banner')!; btnEl = document.getElementById('shutdown-btn') as HTMLButtonElement; + btnEl.replaceChildren(createIcon('moon')); document.getElementById('shutdown-wrap')!.classList.remove('hidden'); buildMenu(); window.devdeck.shutdown.onStatus(applyStatus); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 946d6d3..3ebd9c3 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -26,7 +26,7 @@ outline-offset: 2px; } body { - font-family: "Segoe UI", system-ui, sans-serif; + font-family: var(--dd-font-sans); margin: 0; background: var(--bg); color: var(--text); font-size: 13px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; @@ -44,11 +44,6 @@ body { .winbtn-close:hover { background: var(--bad); color: #fff; } #shell { display: flex; flex: 1; min-height: 0; } -#rail { width: 48px; flex: 0 0 48px; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; background: #101218; border-right: 1px solid var(--border); } -.rail-item { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 8px; font-size: 16px; color: var(--text-dim); cursor: pointer; } -.rail-item:hover { background: var(--surface-hover); color: var(--text); } -.rail-item.active { background: #1b1e3a; border-color: var(--accent); color: var(--accent-hover); box-shadow: inset 2px 0 0 var(--accent); } -.rail-spacer { flex: 1; } #content { flex: 1; overflow-y: auto; } .view { display: none; } diff --git a/src/renderer/usageModal.ts b/src/renderer/usageModal.ts index fe6b467..f9bbe83 100644 --- a/src/renderer/usageModal.ts +++ b/src/renderer/usageModal.ts @@ -2,6 +2,7 @@ import { tr } from './i18n-runtime'; import { createProviderLogo, providerName } from './providerLogo'; import { staleAgeMinutes } from '../shared/usagePresentation'; import { formatReset, usageSeverity, usageStateKey, type ProviderUsage, type UsageSnapshot } from '../shared/usageWindows'; +import { createIcon } from './icons'; // The all-provider limits dialog. It is an OVERLAY (fixed, outside the flex column) precisely so that // opening it cannot change the shell, cockpit, or xterm geometry — a terminal resize storm was a real @@ -20,7 +21,7 @@ export function openUsageModal(snapshot: UsageSnapshot | null, trigger: HTMLElem overlay = document.createElement('div'); overlay.className = 'usage-modal-overlay'; - overlay.innerHTML = `