From 4057d29bc47db23a36247da5cd39bf969b894b56 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 16:39:06 -0700 Subject: [PATCH 01/21] docs: specify hybrid command center redesign --- ...2026-08-09-hybrid-command-center-design.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-hybrid-command-center-design.md 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. From bb73a984b4ee916e1775cc79ae1c90a4935c1c0f Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 16:57:03 -0700 Subject: [PATCH 02/21] docs: plan hybrid command center implementation --- ...09-hybrid-command-center-implementation.md | 582 ++++++++++++++++++ 1 file changed, 582 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-hybrid-command-center-implementation.md 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. From 7b9c02cdcc20edc78b49824f08aaf3ffc2a88c46 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:10:05 -0700 Subject: [PATCH 03/21] feat: add command center design foundation --- package-lock.json | 10 +++++++ package.json | 1 + scripts/copy-assets.mjs | 19 ++++++++++++++ src/renderer/design/base.css | 18 +++++++++++++ src/renderer/design/components.css | 18 +++++++++++++ src/renderer/design/tokens.css | 42 ++++++++++++++++++++++++++++++ src/renderer/icons.test.ts | 17 ++++++++++++ src/renderer/icons.ts | 30 +++++++++++++++++++++ src/renderer/index.html | 3 +++ src/renderer/styles.css | 2 +- 10 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/renderer/design/base.css create mode 100644 src/renderer/design/components.css create mode 100644 src/renderer/design/tokens.css create mode 100644 src/renderer/icons.test.ts create mode 100644 src/renderer/icons.ts 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/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/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..71bb70f --- /dev/null +++ b/src/renderer/design/components.css @@ -0,0 +1,18 @@ +.ui-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; } +.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); } 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/icons.test.ts b/src/renderer/icons.test.ts new file mode 100644 index 0000000..15f53ef --- /dev/null +++ b/src/renderer/icons.test.ts @@ -0,0 +1,17 @@ +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(' = { + search: '', + projects: '', + sessions: '', + tasks: '', + usage: '', + settings: '', + refresh: '', + more: '', + play: '', + 'panel-left': '', +}; + +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..9cf110e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,6 +4,9 @@ + + + diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 946d6d3..51972a6 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; From 1b36a09725dee7138455627032bd47e67f9bf611 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:11:17 -0700 Subject: [PATCH 04/21] feat: model shared command center navigation --- src/shared/shellNavigation.test.ts | 41 ++++++++++++++ src/shared/shellNavigation.ts | 88 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/shared/shellNavigation.test.ts create mode 100644 src/shared/shellNavigation.ts diff --git a/src/shared/shellNavigation.test.ts b/src/shared/shellNavigation.test.ts new file mode 100644 index 0000000..118ea9c --- /dev/null +++ b/src/shared/shellNavigation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + attentionCount, + buildSessionGroups, + filterShellItems, + restoreShellContext, + type ShellSessionInput, +} from './shellNavigation'; + +const rows: ShellSessionInput[] = [ + { id: 'quiet', projectPath: 'C:/quiet', label: 'quiet', detail: 'main', activity: 'idle', pinned: true }, + { id: 'work', projectPath: 'C:/work', label: 'work', detail: 'feat', activity: 'working', pinned: false }, + { id: 'ask', projectPath: 'C:/ask', label: 'ask', detail: 'main', activity: 'attention', pinned: false }, +]; + +describe('shell navigation', () => { + it('orders urgent groups before pins and counts only genuine attention', () => { + expect(buildSessionGroups(rows).map((group) => group.kind)).toEqual(['attention', 'working', 'pinned']); + expect(attentionCount(rows)).toBe(1); + }); + + it('does not duplicate a pinned attention session in the pinned group', () => { + const groups = buildSessionGroups([ + { id: 'ask', projectPath: 'C:/ask', label: 'ask', detail: 'main', activity: 'attention', pinned: true }, + ]); + expect(groups.map((group) => [group.kind, group.items.map((item) => item.id)])).toEqual([ + ['attention', ['ask']], + ]); + }); + + 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 projects = [{ path: 'C:/checkout', name: 'checkout-api', branch: 'main' }]; + expect(filterShellItems('CHECKOUT', rows, projects)).toEqual({ sessions: [], projects }); + expect(filterShellItems('FEAT', rows, projects).sessions.map((item) => item.id)).toEqual(['work']); + }); +}); diff --git a/src/shared/shellNavigation.ts b/src/shared/shellNavigation.ts new file mode 100644 index 0000000..823fb38 --- /dev/null +++ b/src/shared/shellNavigation.ts @@ -0,0 +1,88 @@ +import type { ActivityState } from './sessionStatus'; + +export interface ShellSessionInput { + id: string; + projectPath: string; + label: string; + detail: string; + activity: ActivityState; + pinned: boolean; + previous?: boolean; +} + +export interface ShellProjectInput { + path: string; + name: string; + branch: string | null; +} + +export type ShellGroupKind = 'attention' | 'working' | 'pinned' | 'turn' | 'quiet' | 'previous'; + +export interface ShellSessionGroup { + kind: ShellGroupKind; + items: ShellSessionInput[]; +} + +export type ShellViewId = 'projects' | 'next' | 'usage' | 'settings'; +export type ShellContext = + | { kind: 'view'; id: ShellViewId } + | { kind: 'project'; path: string } + | { kind: 'session'; id: string }; + +const groupOrder: ShellGroupKind[] = ['attention', 'working', 'pinned', 'turn', 'quiet', 'previous']; + +function groupOf(item: ShellSessionInput): ShellGroupKind { + if (item.activity === 'attention') return 'attention'; + if (item.activity === 'working') return 'working'; + if (item.pinned) return 'pinned'; + if (item.previous) return 'previous'; + if (item.activity === 'turn') return 'turn'; + return 'quiet'; +} + +export function buildSessionGroups(items: ShellSessionInput[]): ShellSessionGroup[] { + return groupOrder.flatMap((kind) => { + const grouped = items + .filter((item) => groupOf(item) === kind) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); + return grouped.length ? [{ kind, items: grouped }] : []; + }); +} + +export function attentionCount(items: ShellSessionInput[]): number { + return items.filter((item) => item.activity === 'attention').length; +} + +export function filterShellItems( + query: string, + sessions: ShellSessionInput[], + projects: ShellProjectInput[], +): { sessions: ShellSessionInput[]; projects: ShellProjectInput[] } { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return { sessions: [...sessions], projects: [...projects] }; + return { + sessions: sessions.filter((item) => `${item.label}\n${item.detail}`.toLocaleLowerCase().includes(normalized)), + projects: projects.filter((item) => `${item.name}\n${item.branch ?? ''}`.toLocaleLowerCase().includes(normalized)), + }; +} + +const viewIds = new Set(['projects', 'next', 'usage', 'settings']); + +export function restoreShellContext( + saved: unknown, + availableProjectPaths: Set, + availableSessionIds: Set, +): ShellContext { + if (!saved || typeof saved !== 'object') return { kind: 'view', id: 'projects' }; + const candidate = saved as Partial & { path?: unknown; id?: unknown }; + if (candidate.kind === 'view' && typeof candidate.id === 'string' && viewIds.has(candidate.id as ShellViewId)) { + return { kind: 'view', id: candidate.id as ShellViewId }; + } + if (candidate.kind === 'project' && typeof candidate.path === 'string' && availableProjectPaths.has(candidate.path)) { + return { kind: 'project', path: candidate.path }; + } + if (candidate.kind === 'session' && typeof candidate.id === 'string' && availableSessionIds.has(candidate.id)) { + return { kind: 'session', id: candidate.id }; + } + return { kind: 'view', id: 'projects' }; +} From 451f7b0ec8f09d4418e4a803cf3750b837dd5c63 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:20:34 -0700 Subject: [PATCH 05/21] feat: introduce shared command center shell --- qa/screenshot.mjs | 18 ++++ src/renderer/index.html | 42 ++++++-- src/renderer/locales/en.json | 6 ++ src/renderer/locales/ja.json | 6 ++ src/renderer/locales/ko.json | 6 ++ src/renderer/locales/zh.json | 6 ++ src/renderer/main.ts | 25 ++++- src/renderer/nav.ts | 12 ++- src/renderer/shell.ts | 153 +++++++++++++++++++++++++++++ src/renderer/shell/shell.css | 63 ++++++++++++ src/shared/shellNavigation.test.ts | 8 ++ src/shared/shellNavigation.ts | 4 + 12 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 src/renderer/shell.ts create mode 100644 src/renderer/shell/shell.css diff --git a/qa/screenshot.mjs b/qa/screenshot.mjs index d109296..97a2744 100644 --- a/qa/screenshot.mjs +++ b/qa/screenshot.mjs @@ -85,6 +85,24 @@ async function injectLocalUsage() { // wait for first project render (skeleton -> cards), generous for git scan await win.waitForSelector('#cards .card, #cards .empty', { timeout: 30000 }).catch(() => {}); +// 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 LANGS = ['ko', 'en', 'ja', 'zh']; for (let i = 0; i < LANGS.length; i++) { const l = await lang(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 9cf110e..6d08106 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -8,6 +8,7 @@ + DevDeck @@ -32,17 +33,38 @@
- +
+ + +
+ +
+ +
+
+
+
+ +
+
+
+ +
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 3cb9596..5ef929b 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -24,6 +24,12 @@ "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", "agent.label": "Default agent for new sessions", "agent.claude": "Claude", "agent.antigravity": "Antigravity", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index b2edcce..9e9c997 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -24,6 +24,12 @@ "nav.language": "言語", "nav.next": "次に", "nav.cockpit": "コックピット", + "shell.quick_open": "クイックオープン", + "shell.needs_you": "対応が必要", + "shell.working": "作業中", + "shell.projects": "プロジェクト", + "shell.collapse": "サイドバーを折りたたむ", + "shell.expand": "サイドバーを展開", "agent.label": "新規セッションの既定エージェント", "agent.claude": "Claude", "agent.antigravity": "Antigravity", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 05a5317..009073a 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -24,6 +24,12 @@ "nav.language": "언어", "nav.next": "다음", "nav.cockpit": "콕핏", + "shell.quick_open": "빠른 열기", + "shell.needs_you": "응답 필요", + "shell.working": "작업 중", + "shell.projects": "프로젝트", + "shell.collapse": "사이드바 접기", + "shell.expand": "사이드바 펼치기", "agent.label": "새 세션 기본 에이전트", "agent.claude": "Claude", "agent.antigravity": "Antigravity", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index a4fd0a5..f77341a 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -24,6 +24,12 @@ "nav.language": "语言", "nav.next": "待办", "nav.cockpit": "控制台", + "shell.quick_open": "快速打开", + "shell.needs_you": "需要处理", + "shell.working": "工作中", + "shell.projects": "项目", + "shell.collapse": "收起侧栏", + "shell.expand": "展开侧栏", "agent.label": "新会话的默认代理", "agent.claude": "Claude", "agent.antigravity": "Antigravity", diff --git a/src/renderer/main.ts b/src/renderer/main.ts index d4f3781..4bc08e3 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,6 +1,8 @@ import { mountProjects, renderProjects, reloadProjects } from './projectsView'; import { setCockpitEnabled } from './openRouter'; import { mountNav } from './nav'; +import { mountShell, type ShellController } from './shell'; +import { restoreShellContext } from '../shared/shellNavigation'; import { mountUsage, showUsage } from './usageView'; import { mountSettings, showSettings } from './settingsView'; import { mountNext, showNext } from './nextView'; @@ -13,6 +15,9 @@ import { mountShutdown, refreshShutdownLabels } from './shutdown'; import { initializeAgentSelection, setSelectedAgent } from './agentSelection'; import type { AgentId } from '../shared/types'; +let shellController: ShellController | null = null; +const SHELL_CONTEXT_KEY = 'devdeck:shell-context:v1'; + const toastHost = document.getElementById('toast-host')!; window.devdeck.onError((msg) => { const el = document.createElement('div'); el.className = 'toast'; el.textContent = msg; @@ -87,6 +92,7 @@ function applyStaticLabels(): void { 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(); } function mountTitlebar(): void { @@ -181,7 +187,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,7 +195,23 @@ 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(); + 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: () => nav.show('projects'), + onSession: () => { if (cockpitOn) nav.show('cockpit'); }, + }); + shellController.setCockpitAvailable(cockpitOn); + let saved: unknown = null; + try { saved = JSON.parse(localStorage.getItem(SHELL_CONTEXT_KEY) ?? 'null'); } catch { localStorage.removeItem(SHELL_CONTEXT_KEY); } + const restored = restoreShellContext(saved, new Set(), new Set()); + if (restored.kind === 'view') nav.show(restored.id); const agentSel = document.getElementById('agent-select') as HTMLSelectElement; if (agents.length > 1) { 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/shell.ts b/src/renderer/shell.ts new file mode 100644 index 0000000..72817f9 --- /dev/null +++ b/src/renderer/shell.ts @@ -0,0 +1,153 @@ +import { createIcon, type IconName } from './icons'; +import { tr } from './i18n-runtime'; +import { + buildSessionGroups, + filterShellItems, + 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; + 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; +}): 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')!; + let sessions: ShellSessionInput[] = []; + let projects: ShellProjectInput[] = []; + + 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 renderSessions = (items: ShellSessionInput[]): void => { + sessionHost.replaceChildren(); + for (const group of buildSessionGroups(items)) { + const section = document.createElement('section'); section.className = `shell-group group-${group.kind}`; + const heading = document.createElement('div'); heading.className = 'shell-section-label'; + heading.textContent = `${tr(groupLabels[group.kind])} · ${group.items.length}`; + section.appendChild(heading); + for (const item of group.items) { + const row = document.createElement('button'); row.type = 'button'; row.className = `shell-entity shell-session activity-${item.activity}`; + const signal = document.createElement('span'); signal.className = 'shell-signal'; signal.setAttribute('aria-hidden', 'true'); + const copy = document.createElement('span'); copy.className = 'shell-entity-copy'; + const label = document.createElement('strong'); label.textContent = item.label; + const detail = document.createElement('small'); detail.textContent = item.detail; + copy.append(label, detail); row.append(signal, copy); + row.addEventListener('click', () => options.onSession(item.id)); + section.appendChild(row); + } + sessionHost.appendChild(section); + } + }; + + const renderProjects = (items: ShellProjectInput[]): void => { + projectHost.replaceChildren(); + for (const item of items) { + const row = document.createElement('button'); row.type = 'button'; row.className = 'shell-entity shell-project'; + const copy = document.createElement('span'); copy.className = 'shell-entity-copy'; + const label = document.createElement('strong'); label.textContent = item.name; + const detail = document.createElement('small'); detail.textContent = item.branch ?? '—'; + copy.append(label, detail); row.appendChild(copy); + row.addEventListener('click', () => options.onProject(item.path)); + projectHost.appendChild(row); + } + }; + + 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(); renderSessions(sessions); renderProjects(projects); return; } + resultHost.replaceChildren(); + for (const item of filtered.sessions) { + const button = document.createElement('button'); button.type = 'button'; button.className = 'shell-quick-result'; + button.textContent = `${item.label} — ${item.detail}`; button.addEventListener('click', () => options.onSession(item.id)); 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', () => options.onProject(item.path)); 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(); + }); + 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'); + setCollapsed(sidebar.classList.contains('collapsed')); + renderSessions(sessions); + }; + refreshLabels(); + + return { + showView: options.showView, + setCockpitAvailable: (available) => { + document.getElementById('shell-session-section')!.classList.toggle('hidden', !available); + document.querySelector('.rail-item[data-view="cockpit"]')?.classList.toggle('hidden', !available); + }, + setSessionGroups: (items) => { sessions = [...items]; applyQuery(); }, + setProjects: (items) => { projects = [...items]; applyQuery(); }, + 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..4f9d426 --- /dev/null +++ b/src/renderer/shell/shell.css @@ -0,0 +1,63 @@ +#shell { display: flex; flex: 1; min-height: 0; } + +#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 { padding: 12px 8px 5px; color: var(--dd-text-faint); font-size: var(--dd-fs-meta); 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-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-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) { + #app-sidebar { width: 52px; flex-basis: 52px; align-items: center; } + #app-sidebar .shell-side-brand, #app-sidebar .rail-label, #app-sidebar #shell-quick-wrap, + #app-sidebar .shell-scroll, #app-sidebar .shell-section-label, #app-sidebar .lang-wrap { display: none; } + #app-sidebar .rail-item { width: 36px; justify-content: center; padding: 0; } + #shell-collapse { display: none; } +} diff --git a/src/shared/shellNavigation.test.ts b/src/shared/shellNavigation.test.ts index 118ea9c..2a5e75a 100644 --- a/src/shared/shellNavigation.test.ts +++ b/src/shared/shellNavigation.test.ts @@ -3,6 +3,7 @@ import { attentionCount, buildSessionGroups, filterShellItems, + normalizeSidebarState, restoreShellContext, type ShellSessionInput, } from './shellNavigation'; @@ -38,4 +39,11 @@ describe('shell navigation', () => { expect(filterShellItems('CHECKOUT', rows, projects)).toEqual({ sessions: [], projects }); expect(filterShellItems('FEAT', rows, projects).sessions.map((item) => item.id)).toEqual(['work']); }); + + it('accepts only a persisted boolean sidebar state', () => { + expect(normalizeSidebarState(true)).toBe(true); + expect(normalizeSidebarState(false)).toBe(false); + expect(normalizeSidebarState('true')).toBe(false); + expect(normalizeSidebarState(undefined)).toBe(false); + }); }); diff --git a/src/shared/shellNavigation.ts b/src/shared/shellNavigation.ts index 823fb38..5c6c4dc 100644 --- a/src/shared/shellNavigation.ts +++ b/src/shared/shellNavigation.ts @@ -53,6 +53,10 @@ export function attentionCount(items: ShellSessionInput[]): number { return items.filter((item) => item.activity === 'attention').length; } +export function normalizeSidebarState(value: unknown): boolean { + return value === true; +} + export function filterShellItems( query: string, sessions: ShellSessionInput[], From 07790a1fa5d1cdcc78440c93ab22acecb84d5d44 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:27:34 -0700 Subject: [PATCH 06/21] feat: make project overview row first --- src/main/store.test.ts | 4 +- src/main/store.ts | 2 +- src/renderer/features/projects/projects.css | 89 +++++++++++++++++++++ src/renderer/index.html | 5 +- src/renderer/main.ts | 13 ++- src/renderer/projectOverview.test.ts | 45 +++++++++++ src/renderer/projectOverview.ts | 40 +++++++++ src/renderer/projectsView.ts | 61 +++++++++++--- 8 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 src/renderer/features/projects/projects.css create mode 100644 src/renderer/projectOverview.test.ts create mode 100644 src/renderer/projectOverview.ts diff --git a/src/main/store.test.ts b/src/main/store.test.ts index d320c8b..5d7f694 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'); diff --git a/src/main/store.ts b/src/main/store.ts index fcc35ba..66f4f3e 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -122,7 +122,7 @@ 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); } diff --git a/src/renderer/features/projects/projects.css b/src/renderer/features/projects/projects.css new file mode 100644 index 0000000..0fcded2 --- /dev/null +++ b/src/renderer/features/projects/projects.css @@ -0,0 +1,89 @@ +#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 #cards.as-list { + display: block; + max-width: 1440px; + margin: 0 auto; + padding: 12px 20px 32px; +} + +#view-projects .prow { + grid-template-columns: 20px 10px 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; +} + +@media (max-width: 980px) { + #view-projects .prow { + grid-template-columns: 20px 10px minmax(130px, .8fr) minmax(180px, 1.3fr) auto; + } + + #view-projects .prow-git, + #view-projects .prow-sess { + display: none; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 6d08106..0b19752 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -9,6 +9,7 @@ + DevDeck @@ -78,8 +79,8 @@
- - + +
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 4bc08e3..514d115 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,4 +1,4 @@ -import { mountProjects, renderProjects, reloadProjects } from './projectsView'; +import { currentProjects, focusProject, mountProjects, onProjectsChanged, renderProjects, reloadProjects } from './projectsView'; import { setCockpitEnabled } from './openRouter'; import { mountNav } from './nav'; import { mountShell, type ShellController } from './shell'; @@ -204,10 +204,19 @@ async function boot(): Promise { showView: nav.show, activeView: nav.active, onCollapse: (collapsed) => { setCockpitSidebarCollapsed(collapsed); void window.devdeck.setCockpitSidebar(collapsed); }, - onProject: () => nav.show('projects'), + onProject: (path) => { + nav.show('projects'); + focusProject(path); + localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'project', path })); + }, onSession: () => { if (cockpitOn) nav.show('cockpit'); }, }); shellController.setCockpitAvailable(cockpitOn); + const syncShellProjects = (items: readonly import('../shared/types').ProjectViewModel[]): void => { + shellController?.setProjects(items.filter((item) => !item.hidden).map(({ path, name, branch }) => ({ path, name, branch }))); + }; + onProjectsChanged(syncShellProjects); + syncShellProjects(currentProjects()); let saved: unknown = null; try { saved = JSON.parse(localStorage.getItem(SHELL_CONTEXT_KEY) ?? 'null'); } catch { localStorage.removeItem(SHELL_CONTEXT_KEY); } const restored = restoreShellContext(saved, new Set(), new Set()); diff --git a/src/renderer/projectOverview.test.ts b/src/renderer/projectOverview.test.ts new file mode 100644 index 0000000..8b1aaa7 --- /dev/null +++ b/src/renderer/projectOverview.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { projectRowModel } 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: '', + }); + }); +}); diff --git a/src/renderer/projectOverview.ts b/src/renderer/projectOverview.ts new file mode 100644 index 0000000..c44aa63 --- /dev/null +++ b/src/renderer/projectOverview.ts @@ -0,0 +1,40 @@ +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'; +} + +/** + * 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..666b856 100644 --- a/src/renderer/projectsView.ts +++ b/src/renderer/projectsView.ts @@ -13,6 +13,7 @@ import type { AgentId } from '../shared/types'; import { selectedAgent } from './agentSelection'; import { createProviderOpenControl } from './providerOpenControl'; import { openProjectMemoryModal } from './projectMemoryModal'; +import { projectRowModel } from './projectOverview'; const AUTO_REFRESH_MS = 45_000; @@ -21,6 +22,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 +39,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' = ''; @@ -301,6 +304,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; @@ -379,10 +384,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', () => { @@ -403,17 +411,15 @@ function makeRow(p: ProjectViewModel, live: '' | 'attention' | 'working' = ''): sig.setAttribute('aria-hidden', 'true'); } - 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 +432,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'; @@ -577,6 +583,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 +681,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,6 +707,29 @@ 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; searchEl.placeholder = tr('proj.search_ph'); From 6b72baf3c733c5ff1d817085c53970f7642f18a4 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:36:54 -0700 Subject: [PATCH 07/21] feat: integrate live sessions into command center --- qa/screenshot.mjs | 97 +++++++---------------- src/renderer/cockpitView.ts | 92 +++++++++++++++++---- src/renderer/features/cockpit/cockpit.css | 45 +++++++++++ src/renderer/icons.test.ts | 4 +- src/renderer/icons.ts | 11 ++- src/renderer/index.html | 1 + src/renderer/main.ts | 13 ++- src/shared/cockpitModel.test.ts | 9 ++- src/shared/cockpitModel.ts | 17 ++++ 9 files changed, 202 insertions(+), 87 deletions(-) create mode 100644 src/renderer/features/cockpit/cockpit.css diff --git a/qa/screenshot.mjs b/qa/screenshot.mjs index 97a2744..70929d0 100644 --- a/qa/screenshot.mjs +++ b/qa/screenshot.mjs @@ -328,93 +328,56 @@ 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 inject representative shell rows and measure the real command-center CSS. 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 rowHtml = (name, activity, detail) => ``; + groups.innerHTML = `
${rowHtml(long, 'attention', 'main · Claude · 41%')}
+
${rowHtml(cjk, 'working', 'feature/command-center · Codex · 82%')}
`; 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, + 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).'); +console.log(`unified session sidebar: width=${sidebar.sidebarWidth}px namesInside=${sidebar.inside} detailsInside=${sidebar.detailInside} signals=${sidebar.signals} nestedHidden=${sidebar.nestedHidden} terminalFills=${sidebar.mainFillsWrap}`); +if (sidebar.sidebarWidth !== 224 || !sidebar.inside || !sidebar.detailInside || sidebar.signals !== 2 || !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); } -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).'); - 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 = ''; }); +await win.evaluate(() => { document.getElementById('shell-session-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 diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index de03eae..db22cc4 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -1,7 +1,8 @@ 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'; @@ -15,6 +16,7 @@ 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 }; @@ -23,6 +25,8 @@ interface Live { session: CockpitSession; term: Terminal; fit: FitAddon; search: 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; } const live = new Map(); +const navigationListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); +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 @@ -238,6 +242,58 @@ export function liveSessionsForPersist(): PersistedSession[] { /** How many cockpit sessions are live right now (for the update-restart button label). */ export function liveSessionCount(): number { return live.size; } +function previousNavigationId(entry: PersistedSession): string { + return `previous:${encodeURIComponent(entry.projectPath)}:${encodeURIComponent(entry.sessionId ?? '')}`; +} + +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, labels[index], detailBits.join(' · '), item.pinned); + }); + const previous = previousItems.map((entry, index): ShellSessionInput => ({ + id: previousNavigationId(entry), + projectPath: entry.projectPath, + label: labels[liveItems.length + index], + detail: `${providerName(toAgentId(entry.agentId) ?? 'claude')} · ${tr('cockpit.restore')}`, + activity: 'idle', + pinned: entry.pinned === true, + previous: true, + })); + return [...current, ...previous]; +} + +export function onCockpitNavigationChange(listener: (items: readonly ShellSessionInput[]) => void): () => void { + navigationListeners.add(listener); + listener(cockpitNavigationItems()); + return () => navigationListeners.delete(listener); +} + +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 { + if (live.has(id)) { select(id); return; } + const previous = restorable.find((entry) => previousNavigationId(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 }))); @@ -600,6 +656,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). @@ -730,7 +787,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); @@ -838,10 +896,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 +910,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 +929,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 { @@ -974,8 +1036,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/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/icons.test.ts b/src/renderer/icons.test.ts index 15f53ef..5ffdced 100644 --- a/src/renderer/icons.test.ts +++ b/src/renderer/icons.test.ts @@ -5,13 +5,15 @@ 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', + 'refresh', 'more', 'play', 'panel-left', 'folder', 'plus', 'restart', + 'close', 'pin', 'edit', 'brain', 'clock', ]; for (const name of names) { expect(iconMarkup(name)).toContain(' = { search: '', @@ -13,6 +14,14 @@ const paths: Record = { more: '', play: '', 'panel-left': '', + folder: '', + plus: '', + restart: '', + close: '', + pin: '', + edit: '', + brain: '', + clock: '', }; export function iconMarkup(name: IconName): string { diff --git a/src/renderer/index.html b/src/renderer/index.html index 0b19752..6cf66eb 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -10,6 +10,7 @@ + DevDeck diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 514d115..24d0de2 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -6,7 +6,7 @@ import { restoreShellContext } 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, cockpitNavigationItems, mountCockpit, onCockpitNavigationChange, 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'; @@ -209,9 +209,18 @@ async function boot(): Promise { focusProject(path); localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'project', path })); }, - onSession: () => { if (cockpitOn) nav.show('cockpit'); }, + onSession: (id) => { + if (!cockpitOn) return; + nav.show('cockpit'); + activateCockpitSession(id); + localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'session', id })); + }, }); shellController.setCockpitAvailable(cockpitOn); + if (cockpitOn) { + onCockpitNavigationChange((items) => shellController?.setSessionGroups([...items])); + shellController.setSessionGroups(cockpitNavigationItems()); + } const syncShellProjects = (items: readonly import('../shared/types').ProjectViewModel[]): void => { shellController?.setProjects(items.filter((item) => !item.hidden).map(({ path, name, branch }) => ({ path, name, branch }))); }; diff --git a/src/shared/cockpitModel.test.ts b/src/shared/cockpitModel.test.ts index 0b6fbf7..db90ac7 100644 --- a/src/shared/cockpitModel.test.ts +++ b/src/shared/cockpitModel.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect } from 'vitest'; -import { filterSessions, groupByActivity, needsAttentionCount, isCockpitPlatform, isCockpitAvailable, numberCollidingNames, cockpitListSignature, shouldNotifyAttention, foldProjectActivity, tileHoldingSession, type CockpitSession } from './cockpitModel'; +import { filterSessions, groupByActivity, needsAttentionCount, isCockpitPlatform, isCockpitAvailable, numberCollidingNames, cockpitListSignature, shouldNotifyAttention, foldProjectActivity, sessionNavigationItem, tileHoldingSession, type CockpitSession } from './cockpitModel'; const s = (over: Partial = {}): CockpitSession => ({ id: 'p#1', projectPath: 'C:\\g\\proj', name: 'proj', agentId: 'claude', status: 'running', staleLevel: 'fresh', branch: 'main', dirty: 0, activity: 'working', ...over, }); +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, + }); +}); + describe('filterSessions', () => { it('matches name and branch case-insensitively', () => { const list = [s({ id: 'a', name: 'devdeck' }), s({ id: 'b', name: 'api', branch: 'feat/auth' })]; diff --git a/src/shared/cockpitModel.ts b/src/shared/cockpitModel.ts index 3d1832e..fa78b3d 100644 --- a/src/shared/cockpitModel.ts +++ b/src/shared/cockpitModel.ts @@ -1,5 +1,6 @@ import type { AgentId, StaleLevel } from './types'; import type { ActivityState } from './sessionStatus'; +import type { ShellSessionInput } from './shellNavigation'; export type SessionStatus = 'running' | 'exited'; @@ -15,6 +16,22 @@ export interface CockpitSession { activity: ActivityState; } +export function sessionNavigationItem( + session: CockpitSession, + label: string, + detail: string, + pinned: boolean, +): ShellSessionInput { + return { + id: session.id, + projectPath: session.projectPath, + label, + detail, + activity: session.activity, + pinned, + }; +} + export function filterSessions(list: CockpitSession[], query: string, labels?: Map): CockpitSession[] { const q = query.trim().toLowerCase(); if (!q) return [...list]; From b85f85a75169355a1c3378aa235306a3b5d9e095 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:42:12 -0700 Subject: [PATCH 08/21] feat: present project memory as responsive drawer --- qa/screenshot.mjs | 32 ++++++++++++++++++-- src/renderer/design/components.css | 37 +++++++++++++++++++++++ src/renderer/projectMemoryModal.ts | 21 +++++++++++-- src/renderer/projectMemorySurface.test.ts | 10 ++++++ src/renderer/projectMemorySurface.ts | 5 +++ src/renderer/shell/shell.css | 2 +- 6 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src/renderer/projectMemorySurface.test.ts create mode 100644 src/renderer/projectMemorySurface.ts diff --git a/qa/screenshot.mjs b/qa/screenshot.mjs index 70929d0..5ecf7f4 100644 --- a/qa/screenshot.mjs +++ b/qa/screenshot.mjs @@ -231,8 +231,24 @@ const taskSeeded = await win.evaluate(async () => { await showView('projects'); await win.click('#refresh'); await win.waitForSelector('.project-memory-button', { timeout: 10000 }); -await win.locator('.project-memory-button').first().click(); +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'); await win.setViewportSize({ width: 520, height: 760 }).catch(() => {}); await win.waitForTimeout(150); @@ -240,16 +256,26 @@ 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(() => {}); diff --git a/src/renderer/design/components.css b/src/renderer/design/components.css index 71bb70f..ee07a96 100644 --- a/src/renderer/design/components.css +++ b/src/renderer/design/components.css @@ -16,3 +16,40 @@ .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); } + +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/projectMemoryModal.ts b/src/renderer/projectMemoryModal.ts index 2299bb9..9c41be2 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,12 +50,26 @@ 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(); }; + closeCurrent = close; const focusable = (): HTMLElement[] => Array.from(modal.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')); const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return; } 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/shell/shell.css b/src/renderer/shell/shell.css index 4f9d426..96e14ba 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -35,7 +35,7 @@ background: var(--dd-canvas); color: var(--dd-text); } .shell-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; } -.shell-section-label { padding: 12px 8px 5px; color: var(--dd-text-faint); font-size: var(--dd-fs-meta); text-transform: uppercase; letter-spacing: .07em; } +.shell-section-label { padding: 12px 8px 5px; color: var(--dd-text-muted); font-size: var(--dd-fs-meta); 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; From 82314a4ccf9cdcd58239090b3e71d6ca9f529027 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Sun, 9 Aug 2026 17:47:55 -0700 Subject: [PATCH 09/21] feat: align secondary views with command center --- src/renderer/design/components.css | 1 + src/renderer/features/secondary.css | 203 ++++++++++++++++++++++++++++ src/renderer/icons.test.ts | 2 +- src/renderer/icons.ts | 6 +- src/renderer/index.html | 1 + src/renderer/nextView.ts | 11 +- src/renderer/settingsView.ts | 5 +- src/renderer/usageModal.ts | 5 +- src/renderer/usageView.ts | 2 +- src/shared/i18n.test.ts | 2 + 10 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 src/renderer/features/secondary.css diff --git a/src/renderer/design/components.css b/src/renderer/design/components.css index ee07a96..5b099f4 100644 --- a/src/renderer/design/components.css +++ b/src/renderer/design/components.css @@ -16,6 +16,7 @@ .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; 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 index 5ffdced..2b7289d 100644 --- a/src/renderer/icons.test.ts +++ b/src/renderer/icons.test.ts @@ -6,7 +6,7 @@ describe('iconMarkup', () => { const names: IconName[] = [ 'search', 'projects', 'sessions', 'tasks', 'usage', 'settings', 'refresh', 'more', 'play', 'panel-left', 'folder', 'plus', 'restart', - 'close', 'pin', 'edit', 'brain', 'clock', + 'close', 'pin', 'edit', 'brain', 'clock', 'trash', 'chevron-left', 'chevron-right', ]; for (const name of names) { diff --git a/src/renderer/icons.ts b/src/renderer/icons.ts index e8d80bb..dd56b06 100644 --- a/src/renderer/icons.ts +++ b/src/renderer/icons.ts @@ -1,7 +1,8 @@ export type IconName = | 'search' | 'projects' | 'sessions' | 'tasks' | 'usage' | 'settings' | 'refresh' | 'more' | 'play' | 'panel-left' - | 'folder' | 'plus' | 'restart' | 'close' | 'pin' | 'edit' | 'brain' | 'clock'; + | 'folder' | 'plus' | 'restart' | 'close' | 'pin' | 'edit' | 'brain' | 'clock' + | 'trash' | 'chevron-left' | 'chevron-right'; const paths: Record = { search: '', @@ -22,6 +23,9 @@ const paths: Record = { edit: '', brain: '', clock: '', + trash: '', + 'chevron-left': '', + 'chevron-right': '', }; export function iconMarkup(name: IconName): string { diff --git a/src/renderer/index.html b/src/renderer/index.html index 6cf66eb..4066682 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -11,6 +11,7 @@ + DevDeck 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/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/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 = `