From 9679bfc54e8b2fdfb512550225f141992436d5d9 Mon Sep 17 00:00:00 2001 From: bo Date: Sun, 2 Aug 2026 23:47:47 +0800 Subject: [PATCH 01/11] feat(design-system): finalize workbench prototypes --- AGENTS.md | 6 +- design-system/MASTER.md | 109 +- design-system/pages/automations.md | 64 + design-system/pages/dashboard.md | 105 +- design-system/pages/session.md | 11 +- design-system/pages/sessions.md | 85 ++ design-system/pages/todos.md | 33 +- design-system/prototypes/app.js | 394 ++++++ design-system/prototypes/automations.html | 903 +++++++++++++ design-system/prototypes/dashboard.html | 450 +++---- design-system/prototypes/session.html | 831 +++++++++--- design-system/prototypes/sessions.html | 458 +++++++ design-system/prototypes/styles.css | 627 ++++++++- design-system/prototypes/todos.html | 1488 +++++++++++++++++---- 14 files changed, 4753 insertions(+), 811 deletions(-) create mode 100644 design-system/pages/automations.md create mode 100644 design-system/pages/sessions.md create mode 100644 design-system/prototypes/automations.html create mode 100644 design-system/prototypes/sessions.html diff --git a/AGENTS.md b/AGENTS.md index 862e40e0..04171e8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,9 +111,11 @@ that page's HTML instead of creating page-specific `.css` or `.js` files. The current references are: -- Dashboard: `design-system/prototypes/dashboard.html` -- Session: `design-system/prototypes/session.html` +- Global Home: `design-system/prototypes/dashboard.html` - Todos: `design-system/prototypes/todos.html` +- Automations: `design-system/prototypes/automations.html` +- Sessions: `design-system/prototypes/sessions.html` +- Session detail: `design-system/prototypes/session.html` When browser QA needs an HTTP origin, serve the prototype root without first changing into that directory: diff --git a/design-system/MASTER.md b/design-system/MASTER.md index eaa679e7..50bfe9b9 100644 --- a/design-system/MASTER.md +++ b/design-system/MASTER.md @@ -1,14 +1,18 @@ # Signal Workbench Design System -> Synchronized from the current product UI and implementation on 2026-07-30. +> Target UI specification synchronized with the current effective prototypes on +> 2026-08-02. Until product implementation catches up, current product code +> remains authoritative for existing runtime behavior and state mechanics. > > When designing or implementing a page, read this file first and then read > `pages/[page-name].md`. A page file overrides this Master only where it says > so. The current product UI is authoritative. When a current prototype exists, > use it as a supporting rendered reference: > [`dashboard.html`](prototypes/dashboard.html), -> [`session.html`](prototypes/session.html), or -> [`todos.html`](prototypes/todos.html). +> [`todos.html`](prototypes/todos.html), +> [`automations.html`](prototypes/automations.html), +> [`sessions.html`](prototypes/sessions.html), or +> [`session.html`](prototypes/session.html). ## Product Fit @@ -108,9 +112,12 @@ scheme in prototypes. | `--selection-field` | `#eeedf8` | Quiet selected row | | `--running-field` | `#f1f4e7` | Quiet running row | | `--attention-field` | `#f8f2e8` | Quiet attention band | -| `--rail` | `#191c19` | Project rail | -| `--rail-ink` | `#f4f5f1` | Active rail content | -| `--rail-muted` | `#939a91` | Inactive rail content | +| `--rail` | `#eceeea` | Light project rail | +| `--rail-ink` | `#171917` | Active rail content | +| `--rail-muted` | `#626a62` | Inactive rail content | +| `--rail-hover` | `#e1e3df` | Rail hover field | +| `--rail-active` | `#d7dbd4` | Active project field | +| `--rail-border` | `#cdd1ca` | Rail boundaries and separators | | `--terminal-bg` | `#252620` | Bash output surface | | `--terminal-text` | `#d7d6cd` | Bash output foreground | | `--terminal-muted` | `#aaa99f` | Bash process metadata | @@ -159,6 +166,9 @@ scheme in prototypes. | `--rail` | `#0c0e0c` | Project rail | | `--rail-ink` | `#f2f4ef` | Active rail content | | `--rail-muted` | `#858c83` | Inactive rail content | +| `--rail-hover` | `#1c201c` | Rail hover field | +| `--rail-active` | `#232723` | Active project field | +| `--rail-border` | `#363c36` | Rail boundaries and separators | | `--terminal-bg` | `#0f100e` | Bash output surface | | `--terminal-text` | `#dad9d1` | Bash output foreground | | `--terminal-muted` | `#aaa99f` | Bash process metadata | @@ -206,7 +216,7 @@ Type scale: | User message | 15px | 400–500 | User intent | | Final response | 15px | 400–600 | Agent outcome and supporting detail | | Session / Todos title | 20px | 600 | Active work or page identity | -| Dashboard title | 26px | 700 | Operational overview identity | +| Global Home title | 26px | 700 | Cross-project operational identity | Rules: @@ -266,19 +276,22 @@ Layer scale: Desktop shell defaults: ```text -52px project rail | 264px project navigation | flexible work canvas | 312px inspector +project pages: 52px project rail | flexible work canvas +Session detail: 52px project rail | flexible Session canvas | 312px inspector ``` -- Project navigation is resizable from 210–340px. - Context Inspector is resizable from 280–460px. -- User-adjusted widths persist across visits. Collapse and focus mode never - discard the last expanded width. +- User-adjusted Inspector width persists across visits. Collapse and focus mode + never discard the last expanded width. +- Every project page begins with one 48px project toolbar containing the + project identity, the `Todos / Automations / Sessions` navigation, and project + actions. Do not duplicate this hierarchy in a persistent project sidebar. - Workbench headers use a 64px minimum height and expand when their two-line content needs more room. - Conversation structure follows the flexible work canvas with safe horizontal gutters; prose inside Agent responses uses a 65–72ch reading measure and user messages remain capped at 660px. -- Dashboard content max: 1180px. +- Global Home content max: 1180px. - Todos content max: 1500px. - Main regions scroll independently only where the product structure requires it. @@ -286,9 +299,9 @@ Responsive behavior: | Breakpoint | Behavior | |---|---| -| `>1180px` | Four-region Session shell; inspector and sidebar may collapse | -| `761–1180px` | 52px rail + persisted/resizable sidebar + canvas; inspector becomes a right overlay below the 64px header | -| `≤760px` | 48px rail + canvas; project sidebar and inspector become overlays | +| `>1180px` | Project rail + canvas; Session detail also shows the resizable Inspector | +| `761–1180px` | 52px rail + canvas; Session Inspector becomes a right overlay below the Session header | +| `≤760px` | 48px rail + canvas; project toolbar wraps to two rows when needed and the Session Inspector becomes an overlay | | `<700px` | Todo Board becomes one column | Narrow-screen rules: @@ -308,9 +321,40 @@ Narrow-screen rules: outline SVG icons. - Active project uses a lime edge marker because it represents the live workspace; active in-project navigation uses indigo. -- Project Dashboard and Todos remain explicit destinations above the - Sessions/Automations switcher. -- Session and Automation lists use one compact row per item: +- The project rail is theme-adaptive: warm neutral in light mode and graphite + in dark mode. Its brand mark, hover fields, selected project, separators, and + icon contrast use the matching `--rail-*` tokens; never leave a permanently + black rail inside the light theme. +- The lower rail utility order is global search, Needs you, Settings, then the + theme switch. Global search sits immediately above Needs you after the rail + separator. +- The brand mark opens global Home. Home is a cross-project attention and + resumption surface; it never becomes a second project inventory. +- Opening a project enters Todos by default. The stable project-level + navigation order is `Todos / Automations / Sessions`; do not add a separate + Project Dashboard that restates the same project work. +- On project inventory pages, the project name is the visible page `h1`. The + active project tab labels the work surface, so do not repeat `Todos`, + `Automations`, or `Sessions` as a second visible page title below the toolbar. + Session detail instead uses the Session title as its page `h1`. +- Do not show ordinary entity totals in the project tabs. Session totals grow + without a useful decision boundary, and mismatched Todo, Automation, and + Session count semantics make the navigation harder to interpret. +- Search has two explicit scopes and never relies on placement alone: + - the project rail opens `Search all work` across every registered project + with `Command/Ctrl+K`; + - each inventory page exposes one visible `Filter {entity}` field that only + narrows the current Todo, Automation, or Session surface. +- Use `Search` for navigational result dialogs and `Filter` for in-place list + narrowing. Global results identify their project and entity type and keep + urgency grouping within a project. Do not duplicate the global-search icon + in the project toolbar. +- Todo, Automation, and Session filters share one component: 36px high on + precise pointers, at least 44px on coarse pointers, a search icon, visible + focus ring, the same border/radius/type, and a helpful no-results state. + Page-specific source or view controls may sit beside it without restyling the + field. +- Session and Automation pages use one compact row per item: `state icon → single-line title → optional goal/attention/time marker`. - Use icons for running, completed, Goal, permission, question, and Automation states. Reserve short text tags for states that require immediate user action, @@ -318,13 +362,30 @@ Narrow-screen rules: - Do not repeat ordinary state and time in a second descriptive line. Put the full state explanation in the accessible name and tooltip. - Organize Sessions by decision value: `Needs you`, `Running`, then `Recent`. +- The Sessions page is the full project execution inventory and the only + visible place for the primary `New Session` action. Creating one opens a + direct root Lead Session without first creating a Todo. +- `Direct` describes how a Session was started, not the size or complexity of + its work. Do not describe Direct Sessions as `No Todo`, `Quick`, or small + work; show the root Agent identity as their neutral source context. +- A Todo `Run now` creates and opens a Todo-bound Session; an Automation + invocation creates and opens an Automation-bound Session. Every Session row + and detail header identifies its source as `Todo`, `Automation`, or `Direct`. +- Todo quick capture presents two explicit outcomes in one surface: + - `Save` captures one Idea without starting Agent work; + - `Run now` creates the minimal Todo, moves it into active work, creates its + bound Lead Session, and opens that Session. Discussion and Plan are optional, + not gates for simple work. +- A Session detail always marks `Sessions` as the active project tab. Its + breadcrumb and source metadata preserve the originating Todo or Automation. - Mark the currently open Session with `aria-current="page"` and move that attribute whenever the user changes Sessions. - The theme switch stays at the bottom of the project rail. -- Project navigation and Context Inspector retain resize, collapse, persisted - width, and focus-mode behavior on desktop. -- On mobile, navigation becomes off-canvas; do not replace it with an unrelated - bottom-tab model. +- Context Inspector retains resize, collapse, persisted width, and focus-mode + behavior on desktop. +- On mobile, keep all three project tabs reachable in a second toolbar row when + they do not fit beside the project identity. Do not replace them with an + unrelated bottom-tab model or hide Sessions in overflow. ## Status Language @@ -356,7 +417,7 @@ pulse, and terminal cursor may loop. ### Rows and Cards -- Dashboard and archived/rejected items are rows separated by rules. +- Global Home and archived/rejected items are rows separated by rules. - Todo cards are one card level only; never nest a card inside another card. - Selection uses a 2px indigo inset rule plus border change. - Running or attention rows may use a semantic field and 3px inset rule. @@ -546,6 +607,8 @@ Motion explains state changes; it is not decoration. - [ ] Verify light and dark modes independently. - [ ] Verify 390px, 760px, 1024px, and 1440px widths. - [ ] Confirm no document-level horizontal overflow. +- [ ] Confirm global Home is cross-project and no project page reintroduces a + Project Dashboard or persistent Sessions/Automations sidebar. - [ ] Confirm the Composer Dock, headers, and drawers do not hide content. - [ ] Confirm pending HITL is the first Composer decision surface and Goal uses no progress bar. diff --git a/design-system/pages/automations.md b/design-system/pages/automations.md new file mode 100644 index 00000000..1538a283 --- /dev/null +++ b/design-system/pages/automations.md @@ -0,0 +1,64 @@ +# Automations Page Overrides + +> Read [`../MASTER.md`](../MASTER.md) first. Automations defines recurring or +> repeatable project work. Each invocation opens its own durable Session; the +> Automation definition never replaces Session execution history. + +## Purpose + +Automations lets users define repeated work once, inspect whether it needs +attention, and recover the exact Session produced by every run. + +## Structure + +- Keep the project rail and project toolbar; `Automations` is the active project + tab. Do not repeat an `Automations` page title below the toolbar. +- Use one command row with `Filter Automations` on the left and the single + primary `New Automation` action on the right. +- On wide screens, use a list/detail split: the left side selects one Automation + and the right side shows its definition, schedule, linked Todo, and recent + invocation Sessions. +- At `≤840px`, show the list first; selecting one Automation replaces it with + the detail in the same canvas and exposes a visible back-to-list action. Do + not introduce a second navigation rail or a modal merely to inspect an + existing Automation. + +## Automation List + +- Group rows by decision value: `Needs attention`, `Scheduled`, then `Paused`. +- Each row shows status orbit, name, concise schedule or latest-run context, and + the next actionable state. Use a flat row with separators, not a summary card. +- Selection uses the shared indigo selected treatment. Needs-attention state + keeps explicit text in addition to amber color. +- `Filter Automations` matches stable ID, name, instruction, schedule, linked + Todo title, and visible run state. Show a helpful no-results state in place of + the list without hiding the filter or New Automation action. + +## Selected Detail + +- Preserve name, status, stable ID, updated time, instructions, schedule, + Agent/Profile, workspace, optional linked Todo, and recent runs. +- `Edit` is secondary. `Run now` is the primary action because it creates one + new invocation Session without changing the Automation definition. +- Every recent-run row opens its exact Session URL. Never reuse one generic + Session URL for multiple invocations. +- A linked Todo opens its stable Todo detail URL. Absence of a linked Todo is + valid and does not make the Automation incomplete. + +## New Automation + +- Use one focused editor dialog with a visible close action, name, instructions, + one schedule mode, timezone when relevant, and Agent/Profile choice. +- Keep advanced schedule detail progressively disclosed. Do not turn creation + into a multi-step wizard or require a Todo. +- The dialog may demonstrate creation with prototype feedback; it must not add + another persistent workflow state to the page. + +## Automations-Specific Avoidances + +- restoring the old Automations sidebar beside the page; +- summary metrics, charts, or run-count KPI cards; +- treating an Automation definition as if it were the execution transcript; +- hiding failed invocations behind schedule state; +- sharing one Session identity across multiple runs; +- requiring every Automation to originate from a Todo. diff --git a/design-system/pages/dashboard.md b/design-system/pages/dashboard.md index 031d3757..0cebf3c1 100644 --- a/design-system/pages/dashboard.md +++ b/design-system/pages/dashboard.md @@ -1,68 +1,71 @@ -# Dashboard Page Overrides +# Global Home Page Overrides -> Read [`../MASTER.md`](../MASTER.md) first. This file defines Project Dashboard -> information architecture. +> Read [`../MASTER.md`](../MASTER.md) first. `dashboard.html` is the global Home +> prototype. It is not a Project Dashboard and never appears in the project-level +> `Todos / Automations / Sessions` navigation. ## Purpose -The Dashboard answers four operational questions: +Home answers four cross-project questions: -1. What needs me? -2. What is running? -3. Where can I continue? -4. What is coming next? +1. What needs me now? +2. What is currently running? +3. What is ready for review? +4. What recurring work is coming next? -It is a decision and resumption surface, not an analytics report. +It is a small attention and resumption surface, not another inventory and not an +analytics dashboard. ## Layout -- Context Inspector is absent on this page. -- Keep the project rail. The project-scoped Dashboard also keeps project - navigation; the global Dashboard does not add an empty project sidebar. -- Main content width is at most 1180px. -- Use 46px top, 40px horizontal, and 72px bottom padding on desktop. -- At `≥1001px`, pair Needs attention with Running now in one priority row, then - pair Continue working with a narrower Upcoming region below it. At narrower - widths, all four regions stack in that order. These are compositional surface - bands, not KPI or Bento cards. -- Keep 20px gaps between paired regions and 34px between major rows. -- At `≤760px`, use 16px horizontal padding and reflow row metadata below the - main copy. +- Keep only the theme-adaptive project rail and global work canvas. Do not render + a project toolbar, persistent project sidebar, or project-level New Session. +- The ArchCode brand mark represents the current Home destination. Project icons + open each project's Todos page. +- Main content width is at most 1180px with 40px desktop gutters and 18px narrow + gutters. +- Pair `Needs you` with `Running` and `Ready to review` with `Upcoming` on wide + screens. Stack all four sections at narrower widths. +- Sections are flat operational bands with rules, not KPI or Bento cards. ## Section Order -1. Intro: `Dashboard` and the plain-language purpose. -2. Needs attention. -3. Running now. -4. Continue working. +1. Intro: `Home` and `Across all projects`. +2. Needs you. +3. Running. +4. Ready to review. 5. Upcoming. -Do not lead with metrics, charts, usage totals, or a greeting hero. +Do not add greetings, marketing copy, metrics, charts, or a global Todo board. ## Rows -- Use full-width rows separated by thin rules inside each region, not a grid of - KPI cards. -- Row structure: - `status orbit → title/explanation → time/state + Open`. -- Needs-attention rows use amber field plus a 3px amber inset rule. -- Running rows use lime field plus a 3px lime inset rule. -- Ordinary resumable rows remain neutral and reveal the indigo action. -- Preserve the explanation that tells the user why the item matters now. - -## Content Rules - -- Prefer a small number of actionable items over exhaustive activity. -- Counts belong in compact section badges. -- Time and running duration use tabular or monospace figures. -- Empty sections should explain that no action is required; never show a blank - chart or a zero-value KPI tile. - -## Dashboard-Specific Avoidances - -- Bento or KPI tile layouts; -- velocity, productivity, token, or activity charts without a decision use; -- generic AI recommendations; -- decorative sparklines; -- large marketing hero; -- equal visual weight for attention, running, and inactive work. +- Use one full-width row per actionable item: + `state icon → entity/title/project context → time or action state`. +- Every row identifies its project and entity type because Home crosses project + boundaries. +- Needs-you rows use amber plus `Permission`, `Question`, or `Failed` text. + Running rows use the live orbit plus elapsed time. Review rows say what result + is awaiting review. Upcoming rows identify the Automation schedule. +- Current-project rows use exact Todo, Automation, or Session deep links. Other + project rows may remain explicit prototype actions rather than fake deep links. +- Prefer a few decision-worthy rows over exhaustive recent activity. Home must + never become a second Sessions, Automations, or Todos list. + +## Search and Attention + +- Project-rail `Search all work` remains the only navigational search and covers + all registered projects and work entities. +- The rail `Needs you` control opens the same cross-project attention set in a + compact dialog. It supplements Home; it does not create another page. +- Home has no local filter because it is a curated decision surface, not an + inventory. + +## Home-Specific Avoidances + +- a Project Dashboard destination; +- a persistent Sessions/Automations sidebar; +- New Session or New Todo actions in the Home header; +- repeating every Todo simply because it exists; +- Bento/KPI layouts, velocity charts, usage totals, or decorative sparklines; +- equal visual weight for blocked, running, review-ready, and inactive work. diff --git a/design-system/pages/session.md b/design-system/pages/session.md index d3fbbab2..31803620 100644 --- a/design-system/pages/session.md +++ b/design-system/pages/session.md @@ -31,7 +31,10 @@ away. ## Layout - Use the complete workbench shell: - `project rail → project navigation → Session canvas → context inspector`. + `project rail → project toolbar → Session canvas → context inspector`. +- `Sessions` remains the active project tab for Todo-bound, Automation-bound, + and Direct Session details. Preserve origin through breadcrumb and source + metadata rather than changing the active top-level tab. - Let conversation structure, Work, tools, code, tables, Diffs, and the Composer use the available Session canvas with safe horizontal gutters. - Constrain only long Agent prose to a 65–72ch reading measure. User messages @@ -39,10 +42,10 @@ away. - Let the conversation and Composer Dock share the Session canvas vertically; the dock must not overlay conversation content. - Preserve desktop resize, collapse, persisted-width, and focus-mode behavior - for project navigation and Context Inspector. + for the Context Inspector. - At `≤1180px`, Context Inspector becomes a right overlay. -- At `≤760px`, project navigation also becomes an overlay; the 48px project rail - remains visible. +- At `≤760px`, the project toolbar wraps to keep all three project tabs + reachable; the 48px project rail remains visible. ## Content Order diff --git a/design-system/pages/sessions.md b/design-system/pages/sessions.md new file mode 100644 index 00000000..d06491e8 --- /dev/null +++ b/design-system/pages/sessions.md @@ -0,0 +1,85 @@ +# Sessions Page Overrides + +> Read [`../MASTER.md`](../MASTER.md) first. Sessions is the project execution +> inventory and direct-work entry. It does not replace the Session detail +> workbench defined in [`session.md`](session.md). + +## Purpose + +Sessions lets users recover every durable root execution, understand what needs +attention, and start work directly without manufacturing a Todo first. + +## Structure + +- Keep the project rail and project toolbar; `Sessions` is the active project + tab. +- Do not repeat a visible `Sessions` page title below the toolbar. +- Use one compact command row: local Session filter, one source filter, and the + single primary `New Session` action. +- The list is centered at a maximum width of 1080px and grouped as `Needs you`, + `Running`, then `Recent`. +- Use flat rows and thin separators. Do not add summary cards, charts, runtime + metrics, or a second dashboard above the list. + +## Session Rows + +- Row order is status orbit, Session title and source context, then action state + or elapsed time. +- Every row identifies one source: `Todo`, `Automation`, or `Direct`. +- Todo and Automation sources include the durable parent name. Direct Sessions + show the root Agent identity instead of inventing a parent work item. +- `Needs you` uses an amber icon plus `Permission`, `Question`, or `Failed` text; + `Running` uses the live orbit plus elapsed time; completed Recent rows use a + green check or explicit `Completed` text. +- The whole row opens the exact Session URL. Destructive actions remain in an + overflow menu or the Session detail and never compete with row navigation. + +## Search and Filter + +- `Filter Sessions` covers Session title, stable ID, source type, Todo title, and + Automation name. +- Treat search and source as one left-aligned filter cluster with an 8px gap; + keep `New Session` independently anchored to the far right. Never distribute + the three controls as equal islands across the command row. +- The source filter is one compact, workbench-styled select: `All sources`, + `Todo`, `Automation`, `Direct`. It uses the shared control border, filter + icon, explicit chevron, and focus ring instead of the browser-default chrome. + Do not add another state filter because the decision groups already expose + state. +- A no-results state suggests another Session title or stable ID and keeps the + source filter visible as the explicit way to narrow by origin. +- Project-rail `Search all work` is the only navigational search. The visible + field only filters the Sessions inventory; do not duplicate search in the + project toolbar. + +## New Session + +- `New Session` immediately creates an untitled direct root Lead Session and + opens the Session detail with the composer focused. +- Direct creation expresses `start executing now`; it does not imply that the + work is quick, small, or unsuitable for a longer investigation. Use a Todo + when the user wants a durable brief, PRD, Plan, or acceptance trail around + one or more executions. +- Do not ask the user to choose between Todo and Session in a modal; choosing + the Sessions surface already communicates direct-work intent. +- Do not create a Todo automatically. A later `Create Todo from Session` action + may exist as quiet progressive disclosure, but it is not part of the primary + creation flow. + +## Responsive Behavior + +- At `≤760px`, the project toolbar becomes two rows: project identity/actions + first and all three project tabs second. +- Search takes the full first row of the command surface; source filter and New + Session remain 44px touch targets beneath it. +- Row metadata may wrap to two lines. Hide elapsed time before hiding source, + title, or the action-required state. + +## Sessions-Specific Avoidances + +- restoring the old persistent Sessions sidebar beside this page; +- calling direct work `Quick Session`, `Work`, or `Legacy`; +- showing a `New Session` button inside Todo quick capture; +- treating the page as analytics or adding status summary cards; +- auto-creating Todos for direct Sessions; +- hiding Todo or Automation provenance on Session detail. diff --git a/design-system/pages/todos.md b/design-system/pages/todos.md index eea964f5..7ea5e505 100644 --- a/design-system/pages/todos.md +++ b/design-system/pages/todos.md @@ -12,14 +12,27 @@ They are project-owned lifecycle entities, not Session-local checklists. ## Shared Structure - Context Inspector is absent on Todos. -- Keep the project rail and project navigation. -- Header contains the title, purpose, and the three-way +- Keep the project rail and project toolbar; `Todos` is the active project tab. +- Do not repeat a visible `Todos` page title or product slogan below the toolbar. + The Todo surface begins with a shared `Filter Todos` field and the three-way `Board / Rejected / Archived` switcher. - Place quick capture directly below the header. - Main Todo content may use up to 1500px because Board lanes need horizontal working space. - A selected Todo opens a right detail drawer without navigating away. +## Search and Filter + +- Project-rail `Search all work` is the only navigational search. The visible + Todo field is only an in-place filter for the selected Todo view; do not add + another search icon to the project toolbar. +- `Filter Todos` matches stable ID, title, body/PRD text, and visible runtime + metadata without changing lifecycle state or opening the detail drawer. +- Filtering the Board keeps all four lanes visible and updates lane counts to + the visible matches. Rejected and Archived filter only their selected lists. +- Use the shared entity-filter component and show a no-results message without + replacing quick capture or the active view switcher. + ## Board Surface Desktop uses four lanes: @@ -70,11 +83,17 @@ Lane rules: ## Quick Capture -- Use one horizontal input surface at every width: - `plus icon → title input → Add`. -- The input stays flexible; the 32px Add button remains visible and does not - imply that execution has started. -- Capture creates intent only; it must not imply that execution has started. +- Use one compact input surface with two explicit outcomes: + `plus icon → title input → Save / Run now`. +- `Save` creates one Idea and starts no Agent work. Its confirmation says the + Todo was saved, never that work started. +- `Run now` creates the minimal Todo, places it in In Progress, creates one bound + Lead Session, and opens that Session. It skips Discussion and Plan without + preventing the user from adding either later. +- `Run now` is the single dominant action; `Save` remains a quiet secondary + action. Do not add `New Session` or a Todo-vs-Session chooser to capture. +- At narrow widths the input keeps its full row and the two actions share the + row below it; both remain at least 44px touch targets. ## Todo Detail Drawer diff --git a/design-system/prototypes/app.js b/design-system/prototypes/app.js index b9a4e874..9e76b13b 100644 --- a/design-system/prototypes/app.js +++ b/design-system/prototypes/app.js @@ -5,6 +5,7 @@ const icons = { chevron: "m9 6 6 6-6 6", close: "M6 6l12 12M18 6 6 18", dashboard: "M4 4h6v6H4zm10 0h6v9h-6zM4 14h6v6H4zm10 3h6v3h-6z", + filter: "M4 6h16M7 12h10M10 18h4", grip: "M9 7h.01M15 7h.01M9 12h.01M15 12h.01M9 17h.01M15 17h.01", menu: "M4 7h16M4 12h16M4 17h16", moon: "M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z", @@ -18,10 +19,116 @@ const icons = { todo: "M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01", }; +const baseWorkSearchItems = [ + { + group: "Needs you", + workKey: "todo-profile-defaults", + type: "Session", + tone: "attention", + state: "Permission", + title: "Implementation Session", + meta: "Todo · Model profile defaults per project", + href: "./session.html?todo=todo-profile-defaults&session=sess-profile-implementation", + }, + { + group: "Needs you", + workKey: "todo-worktree-permission", + type: "Todo", + tone: "attention", + state: "Needs you", + title: "Review worktree deletion permission", + meta: "In Progress · 1 bound Session", + href: "./todos.html#todo=todo-worktree-permission", + }, + { + group: "Needs you", + workKey: "automation-aut-health", + type: "Automation", + tone: "attention", + state: "Failed run", + title: "Daily project health check", + meta: "Automation · latest run needs a decision", + href: "./session.html?automation=aut-health&session=sess-aut-health-failed&title=Daily%20project%20health%20check", + }, + { + group: "Running", + workKey: "todo-live-recovery", + type: "Session", + tone: "running", + state: "Running", + title: "Live execution recovery", + meta: "Todo · Live execution recovery · 12m", + href: "./session.html?todo=todo-live-recovery&session=sess-live-recovery", + }, + { + group: "Ready to review", + workKey: "todo-work-search", + type: "Todo", + tone: "review", + state: "Review", + title: "Unify project work search", + meta: "In Progress · Lead + Build · result ready", + href: "./todos.html#todo=todo-work-search", + }, + { + group: "Ready to review", + workKey: "todo-work-search", + defaultVisible: false, + type: "Session", + tone: "review", + state: "Done", + title: "Project work search review", + meta: "Todo · Unify project work search", + href: "./session.html?todo=todo-work-search&session=sess-work-search-review", + }, + { + group: "Recent", + workKey: "automation-aut-regression", + type: "Automation", + tone: "", + state: "Scheduled", + title: "Regression check", + meta: "Weekdays at 09:00 · latest run passed", + href: "./automations.html?automation=aut-regression", + }, + { + group: "Recent", + workKey: "session-profile-review", + type: "Session", + tone: "done", + state: "Done", + title: "Review Session", + meta: "Todo · Model profile defaults per project", + href: "./session.html?todo=todo-profile-defaults&session=sess-profile-review", + }, + { + group: "Recent", + workKey: "session-direct-context-audit", + type: "Session", + tone: "", + state: "Done", + title: "Audit project context loading", + meta: "Direct · Lead · completed", + href: "./session.html?session=sess-legacy-context-audit", + }, + { + group: "Recent", + workKey: "todo-context-presets", + type: "Todo", + tone: "", + state: "Idea", + title: "Add per-project context presets", + meta: "Captured for later · no execution", + href: "./todos.html#todo=todo-context-presets", + }, +]; + function icon(name) { return ``; } +window.archcodePrototypeIcon = icon; + document.querySelectorAll("[data-icon]").forEach((element) => { const name = element.dataset.icon; if (icons[name]) element.innerHTML = icon(name); @@ -57,6 +164,293 @@ function showToast(message) { toastTimer = setTimeout(() => toast.classList.remove("visible"), 2200); } +window.showToast = showToast; + +function readPrototypeSessions() { + try { + const value = JSON.parse(localStorage.getItem("archcode-prototype-sessions") || "[]"); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +window.readArchcodePrototypeSessions = readPrototypeSessions; + +function escapePrototypeHtml(value) { + return String(value).replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]); +} + +try { + const storedTodos = JSON.parse(localStorage.getItem("archcode-prototype-created-todos") || "[]"); + const todoToolbarCount = document.querySelector('.project-toolbar-nav a[href="./todos.html"] .toolbar-count'); + if (todoToolbarCount && Array.isArray(storedTodos)) todoToolbarCount.textContent = String(6 + storedTodos.length); +} catch { + // Keep the static prototype count when local demo state is unreadable. +} + +function readPrototypeTodos() { + try { + const value = JSON.parse(localStorage.getItem("archcode-prototype-created-todos") || "[]"); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +function projectWorkSearchItems() { + return [ + ...readPrototypeSessions().map((item) => { + const href = item.href || `./session.html?session=${encodeURIComponent(item.sessionId)}`; + const source = item.source || (item.automationId || href.includes("automation=") ? "Automation" : item.todoId || href.includes("todo=") ? "Todo" : "Direct"); + const sourceMeta = source === "Direct" ? "Direct · Lead" : source === "Automation" ? "Automation · Invocation" : "Todo · Started with Run now"; + return { + group: item.group || "Running", + workKey: item.workKey || item.todoId || item.automationId || item.sessionId, + type: "Session", + tone: item.tone || "running", + state: item.state || "Running", + title: item.title || item.sessionId, + meta: item.meta || sourceMeta, + href, + }; + }), + ...readPrototypeTodos().map((item) => ({ + group: item.lane === "in_progress" ? "Running" : "Recent", + workKey: item.id, + type: "Todo", + tone: item.lane === "in_progress" ? "running" : "", + state: item.lane === "in_progress" ? "In Progress" : "Idea", + title: item.title, + meta: item.lane === "in_progress" ? "Lead · started with Run now" : "Captured for later · no execution", + href: `./todos.html#todo=${encodeURIComponent(item.id)}`, + })), + ...baseWorkSearchItems, + ]; +} + +const globalProjectSearchItems = [ + { + group: "Projects", + workKey: "project-archcode", + type: "Project", + tone: "running", + state: "Current", + title: "archcode", + meta: "/workspace/archcode", + href: "./todos.html", + }, + { + group: "Projects", + workKey: "project-archcode-readme-demo", + type: "Project", + tone: "", + state: "Recent", + title: "archcode-readme-demo-workspace", + meta: "Recently opened project", + demoAction: "Open archcode-readme-demo-workspace", + }, + { + group: "Projects", + workKey: "project-specra-test", + type: "Project", + tone: "", + state: "Recent", + title: "specra-test-projects", + meta: "Recently opened project", + demoAction: "Open specra-test-projects", + }, +]; + +function globalWorkSearchItems() { + return [ + ...globalProjectSearchItems, + ...projectWorkSearchItems().map((item) => ({ + ...item, + group: "archcode", + meta: `archcode · ${item.meta}`, + })), + ]; +} + +function createSearchDialog({ triggerSelector, scope, title, description, placeholder, shortcut, items, groupOrder, emptyHint }) { + const triggers = [...document.querySelectorAll(triggerSelector)]; + if (!triggers.length) return null; + + const finder = document.createElement("dialog"); + const titleId = `${scope}-search-title`; + finder.className = "session-finder"; + finder.setAttribute("aria-labelledby", titleId); + finder.innerHTML = ` +
+
+
${escapePrototypeHtml(title)}${escapePrototypeHtml(description)}
+ +
+ +
+
`; + document.body.append(finder); + + const input = finder.querySelector("[data-search-input]"); + const results = finder.querySelector("[data-search-results]"); + let restoreFocus = null; + + function rowMarkup(item) { + const content = ` + + ${escapePrototypeHtml(item.type)}${escapePrototypeHtml(item.title)}${escapePrototypeHtml(item.meta)} + ${escapePrototypeHtml(item.state)}`; + if (item.href) return `${content}`; + return ``; + } + + function render(query = "") { + const normalized = query.trim().toLowerCase(); + const matches = items().filter((item) => ( + normalized + ? `${item.group} ${item.type} ${item.title} ${item.meta} ${item.state} ${item.href || ""}`.toLowerCase().includes(normalized) + : item.defaultVisible !== false + )); + const visibleMatches = normalized ? matches : matches.filter((item, index) => ( + matches.findIndex((candidate) => candidate.workKey === item.workKey) === index + )); + const orderedGroups = [...groupOrder, ...new Set(visibleMatches.map((item) => item.group).filter((group) => !groupOrder.includes(group)))]; + const markup = orderedGroups.map((group) => { + const groupItems = visibleMatches.filter((item) => item.group === group); + if (!groupItems.length) return ""; + return `

${escapePrototypeHtml(group)}

${groupItems.map(rowMarkup).join("")}
`; + }).join(""); + results.innerHTML = markup || `

No results match “${escapePrototypeHtml(query)}”.${escapePrototypeHtml(emptyHint)}

`; + } + + function open(trigger = triggers[0]) { + restoreFocus = trigger; + input.value = ""; + render(); + finder.showModal(); + requestAnimationFrame(() => input.focus()); + } + + function close({ restore = true } = {}) { + if (!finder.open) return; + finder.close(); + if (restore) requestAnimationFrame(() => restoreFocus?.focus()); + } + + triggers.forEach((trigger) => trigger.addEventListener("click", () => open(trigger))); + finder.querySelector("[data-close-search]").addEventListener("click", () => close()); + finder.addEventListener("click", (event) => { + if (event.target === finder) close(); + const demoResult = event.target.closest("[data-search-demo-action]"); + if (demoResult) { + showToast(demoResult.dataset.searchDemoAction); + close(); + } + }); + finder.addEventListener("cancel", (event) => { + event.preventDefault(); + close(); + }); + input.addEventListener("input", () => render(input.value)); + + return { finder, open, close }; +} + +const currentProjectName = document.querySelector(".project-identity-copy h1")?.textContent.trim() || "project"; +const globalSearch = createSearchDialog({ + triggerSelector: "[data-open-global-search]", + scope: "global", + title: "Search all work", + description: "Find projects and work across every workspace.", + placeholder: "Search projects, Todos, Sessions, Automations, or IDs", + shortcut: "⌘K", + items: globalWorkSearchItems, + groupOrder: ["Projects", currentProjectName], + emptyHint: "Try a project, Todo title, Session ID, or Automation name.", +}); + +window.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + if (globalSearch?.finder.open) globalSearch.close(); + else globalSearch?.open(); + } +}); + +const attentionItems = [ + { + type: "Session", + title: "Implementation Session", + meta: "Permission · Model profile defaults per project", + state: "Permission", + href: "./session.html?todo=todo-profile-defaults&session=sess-profile-implementation", + }, + { + type: "Todo", + title: "Review worktree deletion permission", + meta: "Lead is waiting on one scoped decision", + state: "Decision", + href: "./todos.html#todo=todo-worktree-permission", + }, + { + type: "Automation", + title: "Daily project health check", + meta: "Latest run stopped before the final report", + state: "Failed run", + href: "./session.html?automation=aut-health&session=sess-aut-health-failed&title=Daily%20project%20health%20check", + }, +]; + +function createAttentionInbox() { + const triggers = [...document.querySelectorAll("[data-open-attention]")]; + if (!triggers.length) return; + + const inbox = document.createElement("dialog"); + inbox.className = "attention-inbox"; + inbox.setAttribute("aria-labelledby", "attention-inbox-title"); + inbox.innerHTML = ` +
+
Needs youDecisions blocking Agent work.
+ +
+
+ ${attentionItems.map((item) => ` + + + ${escapePrototypeHtml(item.type)}${escapePrototypeHtml(item.title)}${escapePrototypeHtml(item.meta)} + ${escapePrototypeHtml(item.state)} + `).join("")} +
`; + document.body.append(inbox); + + let restoreFocus = null; + function close() { + inbox.close(); + requestAnimationFrame(() => restoreFocus?.focus()); + } + + triggers.forEach((trigger) => trigger.addEventListener("click", () => { + restoreFocus = trigger; + inbox.showModal(); + requestAnimationFrame(() => inbox.querySelector("a")?.focus()); + })); + inbox.querySelector("[data-close-attention]").addEventListener("click", close); + inbox.addEventListener("click", (event) => { + if (event.target === inbox) close(); + }); + inbox.addEventListener("cancel", (event) => { + event.preventDefault(); + close(); + }); +} + +createAttentionInbox(); + document.querySelectorAll("[data-action]").forEach((element) => { element.addEventListener("click", () => showToast(element.dataset.action)); }); diff --git a/design-system/prototypes/automations.html b/design-system/prototypes/automations.html new file mode 100644 index 00000000..8b9e0423 --- /dev/null +++ b/design-system/prototypes/automations.html @@ -0,0 +1,903 @@ + + + + + + + ArchCode — Automations Prototype + + + + + + +
+ + +
+
+
+ ar +

archcode

/workspace/archcode
+
+ +
+ +
+
+ +
+
+
+ + +
+ +
+
+
+ +
+

Needs attention

1
+
+ +
+
+ +
+

Scheduled

2
+
+ + +
+
+ +
+

Paused

1
+
+ +
+
+
+
+ +
+
+ +
Scheduledaut-regressionUpdated 2h ago
+
+
+

Regression check

+

Run the focused verification suite against the current Todo implementation and return only actionable failures.

+
+
+ + +
+
+ +
+

Schedule & execution

+
+
TriggerWeekdays at 09:00
+
Next runTomorrow, 09:00
+
AgentLead · principal
+
Workspace/workspace/archcode
+
+
+ +
+

Instructions

+

Read the linked Todo and its Plan, run the focused package checks, and create a durable Session containing the exact result and any failure evidence. Do not change the Todo lifecycle automatically.

+
+ +
+

Linked Todo

+ + Model profile defaults per projectReady · PRD and Plan availableOpen → + +
+ +
+

Recent runs

+ +
+
+
+
+
+
+
+
+ + +
+

New Automation

Define repeated work once; every invocation will create its own Session.

+
+ + + +
+
+
+
+ +
+ + + diff --git a/design-system/prototypes/dashboard.html b/design-system/prototypes/dashboard.html index cb59597a..8afb856a 100644 --- a/design-system/prototypes/dashboard.html +++ b/design-system/prototypes/dashboard.html @@ -3,52 +3,37 @@ - - ArchCode — Dashboard Prototype - + + ArchCode — Home Prototype + - + - +
- - -
-
-
-
- - -
-

Project overview

-

Dashboard

-

What needs you, what is moving, and where to continue.

-
+
+
+
+
+

Across all projects

+

Home

+

Decisions that need you, work that is moving, results ready to review, and the next scheduled runs.

-
-
-
-

Needs attention

0 +
+
+
+

Needs you

2
-
Nothing needs your attention.
+
-
-
-

Running now

0 +
+
+

Running

2
-
No sessions are running.
+
+ + + Live execution recoveryarchcode · Todo-bound Session · Lead → Explore + 12m + + +
-
-
-
-
-

Continue working

2 +
+
+

Ready to review

1
-
-
-
-

Upcoming

1 +
+
+

Upcoming

2
-
-
@@ -412,7 +351,6 @@

Dashboard

-
diff --git a/design-system/prototypes/session.html b/design-system/prototypes/session.html index 7090bfb2..a9ea91c1 100644 --- a/design-system/prototypes/session.html +++ b/design-system/prototypes/session.html @@ -8,29 +8,19 @@ content="ArchCode Session 页面代表性状态原型,覆盖 Reasoning、Tools、Delegation、HITL 与 Queue" /> ArchCode — Session Prototype - + - + - +
- - -
-
+
-

Session execution state audit

Needs you +

+ +

+ Needs you
/workspace/archcode · - 12 tools · 48,206 tokens + 12 tools · 48,206 tokens · - Todo: Verify Session UI + Todo: Model profile defaults per project +
+
- + todo-profile-defaultsOpen Todo
-
+
-

Audit the Session execution UI. Verify tools, delegation, recovery, HITL, and queued follow-up behavior.

+

Implement the project-level model profile recommendation. Preserve explicit Session overrides and the existing global configuration contract.

-
+
- -

First pass complete. The Workstream projection is sound; the remaining risk is interaction coverage around suspended Executions and queued instructions.

+

First pass complete. The recommendation can remain project-owned and apply only when a new root Session has no explicit Profile override.

-
+ + +
-

Run the focused interaction checks and stop before any broad workspace mutation.

+

Add the focused contract coverage. Stop if this requires changing existing Session bindings.

@@ -1352,17 +1512,17 @@

Session execution state audit

Needs you - Needs you · Worked for 38s + Needs you · Worked for 38s -
-

I found the focused test path. Before running the command, I’m checking the exact scope and preserving the current Session state.

+
+

I found the focused creation path. Before running verification, I’m checking the exact command scope and preserving current Session bindings.

-
- The requested check only needs the Web interaction suite. A full monorepo run would add time and unrelated Agent Core process lifecycles. +
+ Resolution order remains explicit Session override, project recommendation, then global principal Profile. Existing Sessions never re-resolve.
@@ -1371,38 +1531,38 @@

Session execution state audit

Needs youfile_read, grep, lsp_diagnostics
-
file_readHitlCard.tsxCompleted
-
grep“Queue another instruction”Completed
-
lsp_diagnosticsExecutionWorkstream.tsxCompleted
+
file_readroutes/sessions.tsCompleted
+
grep“principal” in Profile resolutionCompleted
+
lsp_diagnosticsproject-todos.tsxCompleted
↳ delegate - Verify Session HITL semanticsAnalyst · deep · Completed · 31s + Review Profile precedenceAnalyst · deep · Completed · 31s
@@ -1420,11 +1580,11 @@

Session execution state audit

Needs you
- $ bun run --cwd apps/web test SessionComposerDock.test.tsx + $ bun run --cwd apps/server test project-session-profile.test.ts Waiting for permission before starting the process…
@@ -1436,7 +1596,7 @@

Session execution state audit

Needs you
-
+
Needs attention @@ -1445,13 +1605,13 @@

Session execution state audit

Needs you
-

Run focused Web verification

bash · 1/2 +

Run focused Profile verification

bash · 1/2
-

The Agent wants to run one scoped interaction-test command in apps/web.

+

The Agent wants to run one scoped Server contract test for new Session admission.

- +
‹ 1 / 2 › @@ -1465,11 +1625,11 @@

Run focused Web verification

bash · 1/2
-
+
Queued - After verification, summarize the remaining risk. + After verification, summarize Profile precedence and remaining risk. GPT-5.6 Luna · deep
Steering - Also check narrow-screen overflow. + Also verify the recommendation is visible before Start Work. GPT-5.6 Luna · deep
-
+
- Queue another instruction… - Needs attention + Queue another instruction… + Needs attention Lead · principal - +
@@ -1525,42 +1685,42 @@

Which verification scope should run next?

ask_user · 1/1
L - LeadprincipalSession execution state audit - Needs you + LeadprincipalImplement project Profile recommendation + Needs you
-
+
A - AnalystdeepVerify Session HITL semantics + AnalystdeepReview Profile precedence Completed
-
+
B - BuilddeepExercise composer interactions + BuilddeepAdd Session admission coverage Running
-
+
E - ExplorefastCheck narrow-screen overflow + ExplorefastTrace persisted Profile bindings Stopped
@@ -1570,6 +1730,343 @@

Which verification scope should run next?

ask_user · 1/1
+ + + +
+ + +
+
+
+ ar +

archcode

/workspace/archcode
+
+ +
+ +
+
+ +
+
+ + + +
+ +
+
+ +
+
+
+
+ +
+ + + + diff --git a/design-system/prototypes/styles.css b/design-system/prototypes/styles.css index a2644e5b..7912e6c9 100644 --- a/design-system/prototypes/styles.css +++ b/design-system/prototypes/styles.css @@ -34,9 +34,12 @@ --selection-field: #eeedf8; --running-field: #f1f4e7; --attention-field: #f8f2e8; - --rail: #191c19; - --rail-ink: #f4f5f1; - --rail-muted: #939a91; + --rail: #eceeea; + --rail-ink: #171917; + --rail-muted: #626a62; + --rail-hover: #e1e3df; + --rail-active: #d7dbd4; + --rail-border: #cdd1ca; --focus: 0 0 0 3px rgb(97 87 213 / 23%); --elevation-sm: 0 10px 30px rgb(26 31 25 / 9%); --elevation-md: 0 22px 56px rgb(25 28 22 / 18%); @@ -92,6 +95,9 @@ --rail: #0c0e0c; --rail-ink: #f2f4ef; --rail-muted: #858c83; + --rail-hover: #1c201c; + --rail-active: #232723; + --rail-border: #363c36; --focus: 0 0 0 3px rgb(164 155 255 / 26%); --elevation-sm: 0 14px 34px rgb(0 0 0 / 32%); --elevation-md: 0 22px 56px rgb(0 0 0 / 52%); @@ -102,6 +108,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + html, body { height: 100%; @@ -200,6 +210,215 @@ p { height: 100dvh; } +/* Project toolbar shell: inventory navigation stays above the work canvas. */ +.todo-first-shell .app-shell { + grid-template-columns: var(--rail-width) minmax(0, 1fr); +} + +.todo-first-shell .work-canvas { + display: flex; + flex-direction: column; +} + +.project-toolbar { + position: relative; + z-index: 22; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 18px; + flex: 0 0 48px; + min-width: 0; + padding: 0 14px 0 16px; + border-bottom: 1px solid var(--border-default); + background: var(--bg-surface); +} + +.project-identity { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.project-identity-mark { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 26px; + height: 26px; + border: 1px solid var(--border-default); + border-radius: 5px; + background: var(--bg-muted); + color: var(--brand); + font-size: 10px; + font-weight: 760; +} + +.project-identity-copy { + min-width: 0; +} + +.project-identity-copy h1, +.project-identity-copy strong, +.project-identity-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-identity-copy h1, +.project-identity-copy strong { + display: block; + margin: 0; + font-size: 12px; + font-weight: 680; + line-height: 1.2; +} + +.project-identity-copy span { + margin-top: 2px; + color: var(--text-tertiary); + font-family: var(--font-stack-mono); + font-size: 9px; +} + +.project-toolbar-nav { + display: flex; + align-self: stretch; + align-items: center; + gap: 4px; +} + +.project-toolbar-nav a { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 11px; + border-radius: 5px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 630; +} + +.project-toolbar-nav a:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.project-toolbar-nav a.active { + color: var(--text-primary); +} + +.project-toolbar-nav a.active::after { + position: absolute; + right: 9px; + bottom: -7px; + left: 9px; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--brand); + content: ""; +} + +.toolbar-count { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border: 1px solid var(--border-subtle); + border-radius: 9px; + background: var(--bg-muted); + color: var(--text-tertiary); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.project-toolbar-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.project-toolbar .icon-button:hover { + cursor: pointer; +} + +.project-view { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.surface-filter { + display: flex; + align-items: center; + gap: 8px; + width: min(100%, 420px); + min-width: 0; + height: 36px; + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: 5px; + background: var(--bg-elevated); + color: var(--text-tertiary); + transition: + border-color var(--motion-hover) ease, + box-shadow var(--motion-hover) ease; +} + +.surface-filter:focus-within { + border-color: var(--brand); + box-shadow: var(--focus); +} + +.surface-filter [data-icon] svg { + width: 15px; + height: 15px; +} + +.surface-filter input { + width: 100%; + min-width: 0; + height: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--text-primary); + font-size: 12px; +} + +.surface-filter input::placeholder { + color: var(--text-muted); +} + +.surface-filter-empty { + padding: 28px 12px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} + +.surface-filter-empty strong, +.surface-filter-empty span { + display: block; +} + +.surface-filter-empty strong { + color: var(--text-secondary); + font-weight: 640; +} + +.surface-filter-empty span { + margin-top: 6px; + font-size: 10px; +} + .dashboard-page.sidebar-collapsed .app-shell, .todos-page.sidebar-collapsed .app-shell { grid-template-columns: var(--rail-width) minmax(0, 1fr); @@ -218,7 +437,7 @@ p { min-width: 0; padding: 8px 0; overflow: hidden; - border-right: 1px solid var(--border-default); + border-right: 1px solid var(--rail-border); background: var(--rail); color: var(--rail-muted); } @@ -239,12 +458,12 @@ p { } .brand-mark { - margin-bottom: 4px; - background: var(--bg-elevated); - color: var(--brand); - font-size: 20px; - font-weight: 750; - box-shadow: inset 0 0 0 1px var(--border-subtle); + margin-bottom: 6px; + background: var(--rail-hover); + color: var(--rail-ink); + font-size: 17px; + font-weight: 720; + box-shadow: inset 0 0 0 1px var(--rail-border); } .brand-mark span { @@ -257,23 +476,23 @@ p { .rail-project:hover, .rail-icon:hover { - background: rgb(255 255 255 / 8%); + background: var(--rail-hover); color: var(--rail-ink); } .rail-project.active { - background: rgb(255 255 255 / 12%); + background: var(--rail-active); color: var(--rail-ink); - box-shadow: inset 2px 0 0 var(--brand); + box-shadow: none; } .rail-project.active::before { position: absolute; left: -8px; width: 2px; - height: 24px; + height: 20px; border-radius: 0 2px 2px 0; - background: var(--brand); + background: var(--signal); content: ""; } @@ -284,8 +503,12 @@ p { .rail-separator { width: 32px; height: 1px; - margin: 4px 0; - background: var(--border-subtle); + margin: 6px 0 4px; + background: var(--rail-border); +} + +.rail-search { + margin-bottom: 2px; } .rail-attention-count { @@ -657,6 +880,258 @@ p { transform: none; } +.session-finder { + width: min(620px, calc(100vw - 32px)); + max-height: min(720px, calc(100dvh - 32px)); + padding: 0; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--bg-overlay); + color: var(--text-primary); + box-shadow: var(--elevation-lg); +} + +.session-finder::backdrop { + background: rgba(8, 10, 8, 0.56); +} + +.session-finder-shell { + display: flex; + flex-direction: column; + max-height: min(720px, calc(100dvh - 32px)); +} + +.session-finder-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 16px 12px; + border-bottom: 1px solid var(--border-default); +} + +.session-finder-header strong, +.session-finder-header span { + display: block; +} + +.session-finder-header strong { + font-size: 14px; + font-weight: 680; +} + +.session-finder-header span { + margin-top: 3px; + color: var(--text-tertiary); + font-size: 11px; +} + +.session-finder-search { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + margin: 12px 16px 8px; + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: 6px; + background: var(--bg-base); + color: var(--text-tertiary); +} + +.session-finder-search input { + min-width: 0; + height: 42px; + border: 0; + outline: 0; + background: transparent; + color: var(--text-primary); + font-size: 14px; +} + +.session-finder-search kbd { + color: var(--text-muted); + font-family: var(--font-stack-mono); + font-size: 9px; +} + +.session-finder-results { + min-height: 0; + padding: 4px 8px 14px; + overflow: auto; +} + +.session-finder-group + .session-finder-group { + margin-top: 8px; +} + +.session-finder-group h2 { + padding: 7px 9px 5px; + color: var(--text-tertiary); + font-size: 9px; + font-weight: 720; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.session-finder-row { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + min-height: 50px; + padding: 7px 9px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-secondary); + text-align: left; +} + +.session-finder-row:hover, +.session-finder-row:focus-visible { + background: var(--bg-hover); + color: var(--text-primary); +} + +.session-finder-row strong, +.session-finder-row small { + display: block; +} + +.session-finder-copy { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 3px 7px; +} + +.session-finder-type { + padding: 2px 5px; + border: 1px solid var(--border-default); + border-radius: 4px; + color: var(--text-tertiary); + font-size: 8px; + font-weight: 680; + letter-spacing: 0.03em; + line-height: 1.2; + text-transform: uppercase; +} + +.session-finder-row strong { + overflow: hidden; + font-size: 12px; + font-weight: 640; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-finder-row small { + grid-column: 1 / -1; + overflow: hidden; + color: var(--text-tertiary); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-finder-state { + color: var(--text-tertiary); + font-size: 9px; + font-weight: 650; + white-space: nowrap; +} + +.session-finder-state.attention { color: var(--warning); } +.session-finder-state.running { color: var(--signal-foreground); } +.session-finder-state.review { color: var(--brand); } + +.session-finder-orbit { + width: 9px; + height: 9px; + border: 1px solid var(--text-tertiary); + border-radius: 50%; +} + +.session-finder-orbit.attention { border-color: var(--warning); background: var(--warning); } +.session-finder-orbit.running { border-color: var(--signal); background: var(--signal); } +.session-finder-orbit.done { border-color: var(--success); background: var(--success); } +.session-finder-orbit.review { border-color: var(--brand); background: var(--brand); } + +.session-finder-empty { + padding: 28px 12px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} + +.session-finder-empty strong, +.session-finder-empty span { + display: block; +} + +.session-finder-empty strong { + color: var(--text-secondary); + font-weight: 640; +} + +.session-finder-empty span { + margin-top: 6px; + font-size: 10px; +} + +.attention-inbox { + position: fixed; + inset: auto auto 16px calc(var(--rail-width) + 10px); + width: min(440px, calc(100vw - var(--rail-width) - 26px)); + max-height: min(520px, calc(100dvh - 32px)); + margin: 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--bg-overlay); + color: var(--text-primary); + box-shadow: var(--elevation-lg); +} + +.attention-inbox::backdrop { + background: rgba(8, 10, 8, 0.42); +} + +.attention-inbox-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 14px 14px 11px; + border-bottom: 1px solid var(--border-default); +} + +.attention-inbox-header strong, +.attention-inbox-header span { + display: block; +} + +.attention-inbox-header strong { + font-size: 14px; + font-weight: 680; +} + +.attention-inbox-header span { + margin-top: 3px; + color: var(--text-tertiary); + font-size: 11px; +} + +.attention-inbox-list { + padding: 8px; + overflow: auto; +} + @media (max-width: 760px) { :root { --rail-width: 48px; @@ -667,6 +1142,68 @@ p { grid-template-columns: var(--rail-width) minmax(0, 1fr); } + .todo-first-shell .app-shell, + .todo-first-shell.session-page .app-shell { + grid-template-columns: var(--rail-width) minmax(0, 1fr); + } + + .project-toolbar { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: 44px 44px; + gap: 8px; + flex-basis: 88px; + padding: 0 8px 0 10px; + } + + .project-identity-copy span { + display: none; + } + + .project-toolbar-actions [data-project-actions] { + display: none; + } + + .project-toolbar-actions { + grid-column: 2; + grid-row: 1; + justify-self: end; + } + + .project-toolbar-actions .icon-button { + width: 44px; + height: 44px; + } + + .project-toolbar-nav { + grid-column: 1 / -1; + grid-row: 2; + align-self: stretch; + justify-self: stretch; + border-top: 1px solid var(--border-subtle); + } + + .project-toolbar-nav a { + flex: 1 1 0; + justify-content: center; + gap: 5px; + padding: 0 8px; + } + + .project-toolbar-nav a.active::after { + right: 7px; + bottom: 0; + left: 7px; + } + + .surface-filter { + width: 100%; + height: 44px; + } + + .surface-filter input { + font-size: 16px; + } + .dashboard-page.sidebar-collapsed .app-shell, .todos-page.sidebar-collapsed .app-shell { grid-template-columns: var(--rail-width) minmax(0, 1fr); @@ -711,6 +1248,62 @@ p { .project-rail { padding-top: 6px; } + + .brand-mark, + .rail-project, + .rail-icon { + width: 44px; + height: 44px; + } + + .project-toolbar-nav a { + min-height: 44px; + } + + .session-finder { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + } + + .session-finder-shell { + max-height: calc(100dvh - 20px); + } + + .session-finder-header { + padding: 14px 12px 10px; + } + + .session-finder-header .icon-button { + width: 44px; + height: 44px; + margin: -6px -6px -6px 0; + } + + .session-finder-search { + margin-right: 12px; + margin-left: 12px; + } + + .session-finder-search input { + height: 44px; + font-size: 16px; + } + + .session-finder-row { + min-height: 56px; + } + + .attention-inbox { + inset: auto auto 10px calc(var(--rail-width) + 6px); + width: calc(100vw - var(--rail-width) - 16px); + max-height: calc(100dvh - 20px); + } + + .attention-inbox-header .icon-button { + width: 44px; + height: 44px; + margin: -6px -6px -6px 0; + } } @media (prefers-reduced-motion: reduce) { diff --git a/design-system/prototypes/todos.html b/design-system/prototypes/todos.html index 4387299f..d15f3882 100644 --- a/design-system/prototypes/todos.html +++ b/design-system/prototypes/todos.html @@ -3,61 +3,53 @@ - + ArchCode — Todos Prototype - + - + - +
- - -
-
+
+
- -
-

Todos

-

Shape intent, then start as many discussions or work sessions as useful.

-
+
@@ -562,21 +924,32 @@

Todos

- - - +
+ + + Save it for later, or run it now with a Lead Agent. +
+
+ + +
-
+ + +
-

Ideas

Capture first, shape later

1 +

Ideas

Captured, not committed

1
-
-
- - +
@@ -584,25 +957,75 @@

Todos

-

Ready

Clear enough to hand off

0 +

Ready

Clear enough to start

1
-

Nothing readyMove an idea here when it is ready.

+
+
+ + +
+
-

In Progress

Work underway

0 +

In Progress

Execution is attached here

3
-

No work in progressStart work or drag a Todo here.

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
-

Done

Explicitly completed

0 +

Done

Explicitly accepted

1
-

Nothing completedCompleted Todos stay visible here.

+
+
+ + +
+
-
+
-
-
- + + + +
From be23d97fb2e0866972c35e3c375c272ca78e3752 Mon Sep 17 00:00:00 2001 From: bo Date: Mon, 3 Aug 2026 12:39:03 +0800 Subject: [PATCH 02/11] feat(workbench): replace dashboard with project work hub Make Todos the durable intent anchor while preserving Sessions and Automations as first-class execution inventories. Add global Home and work search, project-level navigation, composed Run now, batched inventories, and derived Todo work status. Hard-cut root Session source and Automation origin contracts and remove the legacy Dashboard and Sidebar implementation. BREAKING CHANGE: Historical project runtime records using the removed Session and Automation source fields are no longer accepted. Delete old project runtime data before launch; no migration or fallback is provided. --- AGENTS.md | 12 +- apps/server/src/app.ts | 6 +- .../src/dashboard-projection-service.ts | 137 ---- apps/server/src/global-work-read-service.ts | 344 ++++++++ apps/server/src/project-inventory-read.ts | 33 + apps/server/src/routes/automations.test.ts | 54 +- apps/server/src/routes/automations.ts | 20 +- apps/server/src/routes/dashboard.test.ts | 176 ----- apps/server/src/routes/dashboard.ts | 30 - apps/server/src/routes/global-work.test.ts | 325 ++++++++ apps/server/src/routes/global-work.ts | 22 + apps/server/src/routes/sessions.test.ts | 84 +- apps/server/src/routes/sessions.ts | 11 +- apps/server/src/routes/todos.test.ts | 140 +++- apps/server/src/routes/todos.ts | 109 +++ apps/web/package.json | 2 +- apps/web/src/api/automations.test.ts | 5 +- apps/web/src/api/mutations.test.ts | 15 +- apps/web/src/api/mutations.ts | 84 +- apps/web/src/api/queries.test.ts | 51 -- apps/web/src/api/queries.ts | 71 +- apps/web/src/api/types.ts | 19 +- .../ExecutionWorkstream.interaction.tsx | 16 + .../composite/ExecutionWorkstream.tsx | 27 +- .../features/AddProjectModal.test.tsx | 4 +- .../components/features/AddProjectModal.tsx | 2 +- .../components/features/ChatHeader.test.tsx | 1 - .../src/components/features/ChatHeader.tsx | 2 - .../web/src/components/features/ChatInput.tsx | 9 + .../features/CloseProjectDialog.test.tsx | 2 +- .../features/CloseProjectDialog.tsx | 2 +- .../ComposerQueueList.interaction.tsx | 6 +- .../components/features/ComposerQueueList.tsx | 19 +- .../features/DeleteResourceDialogs.test.tsx | 2 +- .../EditAutomationDialog.interaction.tsx | 34 +- .../features/EditAutomationDialog.tsx | 97 ++- apps/web/src/components/features/HitlBell.tsx | 6 +- .../components/features/ProjectActionMenu.tsx | 4 +- .../components/features/ProjectBar.test.tsx | 7 +- .../src/components/features/ProjectBar.tsx | 37 +- .../features/ProjectToolbar.test.tsx | 101 +++ .../components/features/ProjectToolbar.tsx | 88 +++ .../features/SessionComposerDock.tsx | 7 +- .../features/Sidebar.interaction.tsx | 451 ----------- apps/web/src/components/features/Sidebar.tsx | 747 ------------------ .../SidebarToggleButton.interaction.tsx | 78 -- .../features/SidebarToggleButton.tsx | 17 - .../components/features/WorkSearchDialog.tsx | 91 +++ .../features/automation-surface.test.ts | 45 +- .../features/compact-control-surface.test.ts | 10 +- .../SessionContextDetails.tsx | 4 +- .../features/orchestration-surface.test.ts | 18 - .../src/context/global-sse-automation.test.ts | 7 +- apps/web/src/context/global-sse.test.tsx | 26 +- apps/web/src/context/global-sse.tsx | 29 +- .../context/workbench-layout.interaction.tsx | 214 +---- apps/web/src/context/workbench-layout.tsx | 49 +- .../web/src/hooks/use-dashboard-projection.ts | 40 - .../src/lib/automation-hitl-attention.test.ts | 2 +- apps/web/src/lib/dashboard-projection.test.ts | 403 ---------- apps/web/src/lib/dashboard-projection.ts | 302 ------- apps/web/src/lib/workbench-layout.test.ts | 19 +- apps/web/src/lib/workbench-layout.ts | 15 - apps/web/src/router.tsx | 24 +- apps/web/src/routes/automation-detail.tsx | 96 ++- apps/web/src/routes/automations.tsx | 207 +++-- apps/web/src/routes/dashboard.tsx | 266 ------- .../{dashboard.test.tsx => home.test.tsx} | 65 +- apps/web/src/routes/home.tsx | 64 ++ .../routes/inventory-classification.test.ts | 106 +++ apps/web/src/routes/project-sessions.tsx | 235 ++++++ .../project-todo-operational-state.test.ts | 158 ++++ .../src/routes/project-todo-presentation.ts | 135 +++- .../src/routes/project-todos.interaction.tsx | 137 +++- apps/web/src/routes/project-todos.test.tsx | 92 ++- apps/web/src/routes/project-todos.tsx | 274 ++++++- apps/web/src/routes/project.test.tsx | 24 + apps/web/src/routes/project.tsx | 18 +- .../src/routes/root-layout.interaction.tsx | 165 +--- apps/web/src/routes/root-layout.tsx | 181 +---- apps/web/src/routes/session.test.tsx | 43 +- apps/web/src/routes/session.tsx | 85 +- apps/web/src/styles/globals.css | 6 +- apps/web/src/styles/globals.test.ts | 2 +- apps/web/src/styles/visual-contract.test.ts | 11 +- design-system/MASTER.md | 23 +- design-system/pages/automations.md | 47 +- design-system/pages/todos.md | 36 +- design-system/prototypes/automations.html | 65 +- ...project-workbench-ui-hard-cut-plan-goal.md | 243 ++++++ .../project-workbench-ui-hard-cut-progress.md | 128 +++ .../src/agents/configured-agent.test.ts | 11 +- .../agent-core/src/agents/configured-agent.ts | 4 +- .../agent-core/src/agents/factory.test.ts | 20 +- .../agents/query/hooks/auto-compact.test.ts | 2 +- .../query/hooks/auto-inject-reminder.test.ts | 2 +- .../query/hooks/hybrid-compression.test.ts | 4 +- .../query/hooks/memory-consolidation.test.ts | 2 +- .../query/hooks/memory-extraction.test.ts | 30 +- .../query/hooks/title-generation.test.ts | 2 +- .../agent-core/src/agents/query/loop.test.ts | 2 +- ...vider-secret-redaction.integration.test.ts | 2 +- .../src/agents/query/recovery.test.ts | 2 +- .../src/agents/root-session-identity.ts | 19 +- .../session-agent-manager.integration.test.ts | 4 +- .../src/agents/session-agent-manager.test.ts | 7 +- .../src/attachments/read-paths.test.ts | 4 +- .../src/automations/dispatcher.test.ts | 10 +- .../agent-core/src/automations/dispatcher.ts | 6 +- .../automations/runtime-session-gateway.ts | 10 +- .../src/automations/scheduler.test.ts | 12 +- .../agent-core/src/automations/scheduler.ts | 7 +- .../agent-core/src/automations/schema.test.ts | 4 +- packages/agent-core/src/automations/schema.ts | 7 +- .../src/automations/state-manager.test.ts | 34 +- .../src/automations/state-manager.ts | 39 +- .../tasks/memory-consolidation.test.ts | 2 +- .../tasks/memory-extraction.test.ts | 50 +- .../background/tasks/title-generation.test.ts | 2 +- .../agent-core/src/commands/compact.test.ts | 16 +- .../agent-core/src/commands/skill.test.ts | 2 +- .../agent-core/src/compact/compact.test.ts | 4 +- .../src/compression/resilience.test.ts | 2 +- ...wd-reference-migration.integration.test.ts | 11 +- .../session-execution-manager.test.ts | 148 ++-- .../session-family-stop-service.test.ts | 4 +- .../session-tool-batch-scheduler.test.ts | 2 +- ...ead-architecture-flows.integration.test.ts | 7 +- packages/agent-core/src/main.test.ts | 64 +- .../src/projects/context-resolver.test.ts | 5 +- .../src/projects/context-resolver.ts | 2 +- packages/agent-core/src/projects/types.ts | 2 +- .../runtime-automations.integration.test.ts | 4 +- .../src/runtime-automations.test.ts | 59 +- packages/agent-core/src/runtime.ts | 85 +- .../src/session-goal/service.test.ts | 4 +- .../src/session-input/model-selection.test.ts | 4 +- .../src/session-input/service.test.ts | 24 +- .../agent-core/src/session-input/service.ts | 14 + packages/agent-core/src/store/helpers.test.ts | 38 +- packages/agent-core/src/store/helpers.ts | 76 +- .../src/store/logical-execution.test.ts | 6 +- .../src/store/message-phase-hard-cut.test.ts | 4 +- .../src/store/session-store-manager.test.ts | 93 +-- .../src/store/session-store-manager.ts | 27 +- packages/agent-core/src/store/store.test.ts | 31 +- packages/agent-core/src/store/types.ts | 6 +- packages/agent-core/src/todos/errors.ts | 23 + packages/agent-core/src/todos/schema.ts | 16 + packages/agent-core/src/todos/service.test.ts | 133 +++- packages/agent-core/src/todos/service.ts | 192 ++++- .../src/todos/state-manager.test.ts | 23 +- .../agent-core/src/todos/state-manager.ts | 64 +- .../tool-output/artifact-lifecycle.test.ts | 1 + .../src/tool-output/finalizer.test.ts | 2 +- .../tool-output/live-bash.integration.test.ts | 1 + .../user-story.integration.test.ts | 1 + .../src/tools/builtins/ask-user.test.ts | 5 +- .../tools/builtins/automation-create.test.ts | 15 +- .../src/tools/builtins/automation-create.ts | 2 +- .../tools/builtins/background-output.test.ts | 4 +- .../src/tools/builtins/bash.test.ts | 2 +- .../src/tools/builtins/cancel-session.test.ts | 4 +- .../src/tools/builtins/compress.test.ts | 2 +- .../src/tools/builtins/delegate.test.ts | 4 +- .../builtins/lsp/lsp-diagnostics.test.ts | 20 +- .../builtins/lsp/lsp-find-references.test.ts | 18 +- .../builtins/lsp/lsp-goto-definition.test.ts | 18 +- .../tools/builtins/lsp/lsp-symbols.test.ts | 18 +- .../builtins/project-todo-update.test.ts | 8 +- .../src/tools/builtins/project-todo-update.ts | 4 +- .../src/tools/builtins/resume-session.test.ts | 2 +- .../src/tools/builtins/session-goal.test.ts | 6 +- .../src/tools/builtins/todo-write.test.ts | 2 +- .../tools/builtins/wait-for-reminder.test.ts | 2 +- .../builtins/worktree.integration.test.ts | 16 +- packages/agent-core/src/tools/github.test.ts | 6 +- .../agent-core/src/tools/registry.test.ts | 2 +- .../src/tools/test-project-context.ts | 5 +- packages/protocol/src/dashboard.ts | 68 -- packages/protocol/src/index.ts | 2 +- packages/protocol/src/project-todos.ts | 34 +- packages/protocol/src/types.test.ts | 2 +- packages/protocol/src/types.ts | 25 +- packages/protocol/src/workbench.ts | 82 ++ 185 files changed, 5308 insertions(+), 4296 deletions(-) delete mode 100644 apps/server/src/dashboard-projection-service.ts create mode 100644 apps/server/src/global-work-read-service.ts create mode 100644 apps/server/src/project-inventory-read.ts delete mode 100644 apps/server/src/routes/dashboard.test.ts delete mode 100644 apps/server/src/routes/dashboard.ts create mode 100644 apps/server/src/routes/global-work.test.ts create mode 100644 apps/server/src/routes/global-work.ts create mode 100644 apps/web/src/components/features/ProjectToolbar.test.tsx create mode 100644 apps/web/src/components/features/ProjectToolbar.tsx delete mode 100644 apps/web/src/components/features/Sidebar.interaction.tsx delete mode 100644 apps/web/src/components/features/Sidebar.tsx delete mode 100644 apps/web/src/components/features/SidebarToggleButton.interaction.tsx delete mode 100644 apps/web/src/components/features/SidebarToggleButton.tsx create mode 100644 apps/web/src/components/features/WorkSearchDialog.tsx delete mode 100644 apps/web/src/hooks/use-dashboard-projection.ts delete mode 100644 apps/web/src/lib/dashboard-projection.test.ts delete mode 100644 apps/web/src/lib/dashboard-projection.ts delete mode 100644 apps/web/src/routes/dashboard.tsx rename apps/web/src/routes/{dashboard.test.tsx => home.test.tsx} (51%) create mode 100644 apps/web/src/routes/home.tsx create mode 100644 apps/web/src/routes/inventory-classification.test.ts create mode 100644 apps/web/src/routes/project-sessions.tsx create mode 100644 apps/web/src/routes/project-todo-operational-state.test.ts create mode 100644 apps/web/src/routes/project.test.tsx create mode 100644 docs/goals/project-workbench-ui-hard-cut-plan-goal.md create mode 100644 docs/goals/project-workbench-ui-hard-cut-progress.md delete mode 100644 packages/protocol/src/dashboard.ts create mode 100644 packages/protocol/src/workbench.ts diff --git a/AGENTS.md b/AGENTS.md index 04171e8a..bb385f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ apps/server/src/ ├── resolve.ts # Request path resolution ├── validation.ts # Hono validator + Zod → BadRequestError thin adapter ├── serve-web.ts # Embedded web asset serving through an explicit asset-map input -├── routes/ # Route modules: setup, auth, dashboard, directories, files, +├── routes/ # Route modules: setup, auth, global-work, directories, files, │ # automations, global-events, hitl, mcp, messages, │ # permissions, projects, questions, sessions, todos └── events/ # global-event-bus.ts @@ -443,7 +443,7 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in ## Session Store -Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and, when present, an immutable `projectTodo` source; an optional `goal` belongs only to a root Lead Session. `projectTodo` is valid only on a user-facing root and records `{ todoId, entry }`, where entry is `discussion`, `work`, or `automation`; strict identity validation requires `discussion` entry ↔ Discussion Agent and `work`/`automation` entry ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. +Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and exactly one immutable `RootSessionSource` on every root: `direct`, `todo { todoId, entry }`, or `automation { automationId, invocationId }`; children never copy a root source. An optional `goal` belongs only to a root Lead Session. Strict identity validation requires Todo `discussion` entry ↔ Discussion Agent and every other root source ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. ## Context Compaction @@ -459,17 +459,17 @@ Project: `.archcode/runtime/memory/`, User: `~/.archcode/memory/` (user-global, ## Project Todos -Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Each Project opens its `/projects/:slug/todos` board by default, while `/projects/:slug` remains the Project Dashboard. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, and the one canonical array order. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update and creates a root Discussion Session for `discussion`, or a root Lead Session for `work` and `automation`. A Todo never points back to Sessions, Plans, or Automations. +Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Global `/` is Home; `/projects/:slug` redirects to the Project's `/projects/:slug/todos` board. Project pages share one `Todos / Automations / Sessions` toolbar, while Sessions remain independently creatable and recoverable execution workbenches. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, the canonical array order, and narrow durable Run-now receipts. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update, the composed `Run now` command, and root Session creation for `discussion`, `work`, and `automation`. A Todo never stores reverse Session, Plan, or Automation links. -A Todo can have any number of direct root Sessions. Each such root stores its immutable `{ todoId, entry }` source; children never copy it. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. **Generate / Improve Plan** reuses the latest Discussion only when it is idle, then invokes `plan-work` for the unique `.archcode/plans/.md`. If no Discussion exists, the latest one is busy or suspended, it was deleted, or an idle reuse loses the acceptance race, the action creates a new Discussion whose first accepted message is the Plan request; it never races a generic Discussion start with a second command. No Plan existence is stored or exposed through Todo APIs. `work` and `automation` roots may start only from Ready or In Progress. At work creation only, `ProjectTodoService` checks that Plan path: an existing file starts with `execute-plan`, while no file preserves ordinary implementation behavior. Starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. Creating an Automation copies the source `todoId` into the Automation's own optional `projectTodoId`; Automation Invocation Sessions are not direct Todo relations. Todo moves never create, stop, rebind, or delete Sessions or Automations. +A Todo can have any number of root Sessions with immutable `{ kind: "todo", todoId, entry }` source. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. **Generate / Improve Plan** reuses the latest Discussion only when it is idle, then invokes `plan-work` for the unique `.archcode/plans/.md`. If no Discussion exists, the latest one is busy or suspended, it was deleted, or an idle reuse loses the acceptance race, the action creates a new Discussion whose first accepted message is the Plan request; it never races a generic Discussion start with a second command. Plan existence is not persisted; the Todo Plan endpoint only performs a fixed-path, bounded Markdown read. `work` and `automation` roots may start only from Ready or In Progress. At work creation only, `ProjectTodoService` checks that Plan path: an existing file starts with `execute-plan`, while no file preserves ordinary implementation behavior. Starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. A Todo-created Automation stores immutable `{ kind: "todo", todoId, sessionId }` origin; every `start_session` Invocation creates a root with its own immutable Automation/Invocation source. Todo moves never create, stop, rebind, or delete Sessions or Automations. ## HITL -HITL is a durable project-scoped approval/question queue backed by `.archcode/runtime/hitl-queue.json`. Server and Web routes expose redacted `displayPayload` data for approval/dashboard views; raw sensitive payloads must not be rendered or persisted in UI state. Deferred permission/question flows resolve safely on timeout, cancellation, or shutdown so long-running agent execution is not left hanging. +HITL is a durable project-scoped approval/question queue backed by `.archcode/runtime/hitl-queue.json`. Server and Web routes expose redacted `displayPayload` data for approval/Home views; raw sensitive payloads must not be rendered or persisted in UI state. Deferred permission/question flows resolve safely on timeout, cancellation, or shutdown so long-running agent execution is not left hanging. ## Automation System -`packages/agent-core/src/automations/` owns schedule calculation, durable Invocation persistence, and dispatch to the ordinary Session API. After the user confirms the creation summary, a root Lead Session calls `automation_create` and commits the Automation through the existing scheduler/state path; Discussion does not expose that capability. When the creating root has a Todo source, the new Automation copies its `todoId` into its own `projectTodoId`; Invocation Sessions do not inherit that source. An Automation has exactly one `once`, `interval`, or `cron + timezone` trigger and one action: create an ordinary Lead Session or send a message to an existing Session. Session execution, Agent behavior, permissions, HITL, Session Goal state, and worktree lifecycle remain outside Automation. +`packages/agent-core/src/automations/` owns schedule calculation, durable Invocation persistence, and dispatch to the ordinary Session API. An Automation has exactly one immutable origin: `direct`, `session { sessionId }`, or `todo { todoId, sessionId }`. Automations UI creation is direct; a root Lead Session may call `automation_create`, with Runtime deriving the Session/Todo origin; Discussion does not expose that capability. An Automation has exactly one `once`, `interval`, or `cron + timezone` trigger and one action: create a root Lead Session with the principal Profile or send a message to an existing Session without changing its identity/source. Session execution, Agent behavior, permissions, HITL, Session Goal state, and worktree lifecycle remain outside Automation. ## LSP Integration diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 8da704cc..cef76075 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -11,7 +11,7 @@ import { createConfigRoutes } from "./routes/config"; import { createAgentsRoutes } from "./routes/agents"; import { createCompressionRoutes } from "./routes/compression"; import { createDirectoriesRoutes } from "./routes/directories"; -import { createDashboardRoutes } from "./routes/dashboard"; +import { createGlobalWorkRoutes } from "./routes/global-work"; import { createFilesRoutes } from "./routes/files"; import { createGlobalEventsRoutes } from "./routes/global-events"; import { createHitlRoutes } from "./routes/hitl"; @@ -60,7 +60,7 @@ export function createRuntimeApp( globalEventBus.emit(snapshot.hitl); }, }); - const dashboard = createDashboardRoutes(serverRuntime); + const globalWork = createGlobalWorkRoutes(serverRuntime); const projectHitl = createHitlRoutes(serverRuntime); const automations = createAutomationsRoutes(serverRuntime); const todos = createTodosRoutes(serverRuntime); @@ -85,7 +85,7 @@ export function createRuntimeApp( const mcp = createMcpRoutes(serverRuntime); const config = createConfigRoutes(serverRuntime.configService); - app.route("/api", dashboard); + app.route("/api", globalWork); app.route("/api/projects", projects); app.route("/api/projects", automations); app.route("/api/projects", todos); diff --git a/apps/server/src/dashboard-projection-service.ts b/apps/server/src/dashboard-projection-service.ts deleted file mode 100644 index 515532f1..00000000 --- a/apps/server/src/dashboard-projection-service.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; -import type { - DashboardAutomation, - DashboardAutomationInvocation, - DashboardExecution, - DashboardProjection, - DashboardProjectError, - DashboardRootSession, - DashboardScope, - SessionExecutionRecord, -} from "@archcode/protocol"; - -/** - * Builds the Dashboard's transient read model from the owning domains. - * - * This service deliberately owns no state: Session, Goal, Automation, and - * Invocation records remain authoritative in their existing stores. - */ -export class DashboardProjectionService { - readonly #runtime: AgentRuntime; - - constructor(runtime: AgentRuntime) { - this.#runtime = runtime; - } - - async read(scope: DashboardScope): Promise { - if (scope.kind === "project") { - const project = await this.#runtime.projectRegistry.get(scope.projectSlug); - if (project === undefined) throw new Error(`Project not found: ${scope.projectSlug}`); - return { - scope, - ...await this.#readProject(project), - errors: [], - }; - } - - const sessions: DashboardRootSession[] = []; - const automations: DashboardAutomation[] = []; - const errors: DashboardProjectError[] = []; - - for (const project of await this.#runtime.projectRegistry.list()) { - try { - const projection = await this.#readProject(project); - sessions.push(...projection.sessions); - automations.push(...projection.automations); - } catch (error) { - errors.push(toProjectError(project, error)); - } - } - - return { scope, sessions, automations, errors }; - } - - async #readProject(project: ProjectInfo): Promise<{ - sessions: DashboardRootSession[]; - automations: DashboardAutomation[]; - }> { - const [summaries, sourceAutomations] = await Promise.all([ - this.#runtime.listSessions(project.workspaceRoot), - this.#runtime.listAutomations(project.workspaceRoot), - ]); - const roots = summaries.filter((session) => session.parentSessionId === undefined); - const [sessions, automations] = await Promise.all([ - Promise.all(roots.map(async (summary) => { - const file = await this.#runtime.getSessionFile(project.workspaceRoot, summary.sessionId); - if (file.parentSessionId !== undefined || file.rootSessionId !== file.sessionId) { - throw new Error(`Dashboard root Session is invalid: ${summary.sessionId}`); - } - return { - projectSlug: project.slug, - projectName: project.name, - rootSessionId: file.sessionId, - sessionTitle: file.title, - createdAt: file.createdAt, - updatedAt: file.updatedAt, - ...(file.goal === undefined ? {} : { goal: file.goal }), - ...(latestExecution(file.executions) === undefined - ? {} - : { latestExecution: toDashboardExecution(latestExecution(file.executions)!) }), - } satisfies DashboardRootSession; - })), - Promise.all(sourceAutomations.map(async (automation) => { - const latest = (await this.#runtime.listAutomationInvocations(project.workspaceRoot, automation.id, 1)).at(-1); - return { - projectSlug: project.slug, - projectName: project.name, - id: automation.id, - name: automation.name, - status: automation.status, - createdAt: automation.createdAt, - updatedAt: automation.updatedAt, - ...(automation.nextFireAt === undefined ? {} : { nextFireAt: automation.nextFireAt }), - ...(latest === undefined ? {} : { latestInvocation: toDashboardInvocation(latest) }), - } satisfies DashboardAutomation; - })), - ]); - - return { sessions, automations }; - } -} - -function latestExecution(executions: readonly SessionExecutionRecord[]): SessionExecutionRecord | undefined { - return executions.at(-1); -} - -function toDashboardExecution(execution: SessionExecutionRecord): DashboardExecution { - return { - id: execution.id, - status: execution.status, - startedAt: execution.startedAt, - ...(execution.endedAt === undefined ? {} : { endedAt: execution.endedAt }), - }; -} - -function toDashboardInvocation(invocation: { - id: string; - status: DashboardAutomationInvocation["status"]; - sessionId?: string; - createdAt: string; - completedAt?: string; -}): DashboardAutomationInvocation { - return { - id: invocation.id, - status: invocation.status, - ...(invocation.sessionId === undefined ? {} : { sessionId: invocation.sessionId }), - createdAt: invocation.createdAt, - ...(invocation.completedAt === undefined ? {} : { completedAt: invocation.completedAt }), - }; -} - -function toProjectError(project: ProjectInfo, error: unknown): DashboardProjectError { - return { - projectSlug: project.slug, - projectName: project.name, - message: error instanceof Error ? error.message : String(error), - }; -} diff --git a/apps/server/src/global-work-read-service.ts b/apps/server/src/global-work-read-service.ts new file mode 100644 index 00000000..d57c7bc7 --- /dev/null +++ b/apps/server/src/global-work-read-service.ts @@ -0,0 +1,344 @@ +import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; +import type { + Automation, + GlobalSSEHitlEntry, + HomeResponse, + HomeSummaryItem, + ProjectAutomationInventoryItem, + ProjectSessionInventoryItem, + ProjectTodo, + RootSessionSummary, + SessionFamilyActivity, + SessionSummary, + WorkbenchProjectReadError, + WorkSearchResponse, + WorkSearchResult, +} from "@archcode/protocol"; + +import { + readProjectAutomationInventory, + readProjectSessionInventory, +} from "./project-inventory-read"; + +interface ProjectHomeFacts { + readonly project: ProjectInfo; + readonly sessions: ProjectSessionInventoryItem[]; + readonly automations: ProjectAutomationInventoryItem[]; + readonly todos: readonly ProjectTodo[]; + readonly hitl: readonly GlobalSSEHitlEntry[]; + readonly activities: ReadonlyMap>; +} + +const SEARCH_LIMIT = 100; + +/** Stateless, cross-project Home and global-search composition boundary. */ +export class GlobalWorkReadService { + readonly #runtime: AgentRuntime; + + constructor(runtime: AgentRuntime) { + this.#runtime = runtime; + } + + async readHome(): Promise { + const projects = await this.#runtime.projectRegistry.list(); + const settled = await Promise.allSettled(projects.map(async (project) => await this.#readProjectHome(project))); + const needsYou: HomeSummaryItem[] = []; + const running: HomeSummaryItem[] = []; + const readyToReview: HomeSummaryItem[] = []; + const upcoming: HomeSummaryItem[] = []; + const projectErrors: WorkbenchProjectReadError[] = []; + + settled.forEach((result, index) => { + const project = projects[index]!; + if (result.status === "rejected") { + projectErrors.push(toProjectError(project, result.reason)); + return; + } + const projection = projectHomeProjection(result.value); + needsYou.push(...projection.needsYou); + running.push(...projection.running); + readyToReview.push(...projection.readyToReview); + upcoming.push(...projection.upcoming); + }); + + needsYou.sort(compareNeedsYou); + running.sort(compareNewestFirst); + readyToReview.sort(compareNewestFirst); + upcoming.sort((left, right) => left.sortAt - right.sortAt || left.href.localeCompare(right.href)); + return { needsYou, running, readyToReview, upcoming, projectErrors }; + } + + async search(query: string): Promise { + const normalized = query.trim(); + const projects = await this.#runtime.projectRegistry.list(); + const settled = await Promise.allSettled(projects.map(async (project) => { + const [summaries, automations, context] = await Promise.all([ + this.#runtime.listSessions(project.workspaceRoot), + this.#runtime.listAutomations(project.workspaceRoot), + this.#runtime.contextResolver.resolve(project.workspaceRoot), + ]); + const sessions = summaries + .filter((session) => session.parentSessionId === undefined) + .map(requireRootSummary); + return { project, sessions, automations, todos: await context.todos.listTodos() }; + })); + const matches: WorkSearchResult[] = []; + const projectErrors: WorkbenchProjectReadError[] = []; + + settled.forEach((result, index) => { + const project = projects[index]!; + if (result.status === "rejected") { + projectErrors.push(toProjectError(project, result.reason)); + } else { + matches.push(...searchProject(normalized, result.value)); + } + }); + + return { + results: matches.slice(0, SEARCH_LIMIT), + truncated: matches.length > SEARCH_LIMIT, + projectErrors, + }; + } + + async #readProjectHome(project: ProjectInfo): Promise { + const [sessions, automations, context, controlPlane] = await Promise.all([ + readProjectSessionInventory(this.#runtime, project.workspaceRoot), + readProjectAutomationInventory(this.#runtime, project.workspaceRoot), + this.#runtime.contextResolver.resolve(project.workspaceRoot), + this.#runtime.getProjectControlPlaneSnapshot(project.workspaceRoot, project.slug), + ]); + return { + project, + sessions, + automations, + todos: await context.todos.listTodos(), + hitl: controlPlane.hitl.entries, + activities: new Map(controlPlane.sessionRuntime.families.map((family) => [ + family.rootSessionId, + family.activity as Exclude, + ])), + }; + } +} + +function projectHomeProjection(facts: ProjectHomeFacts): Omit { + const project = { slug: facts.project.slug, name: facts.project.name }; + const sessionsById = new Map(facts.sessions.map((item) => [item.session.sessionId, item])); + const attentionRootIds = new Set(); + const attentionAutomationIds = new Set(); + const hitlRootIds = new Set(facts.hitl.map((entry) => entry.rootSessionId)); + const needsYou: HomeSummaryItem[] = []; + + for (const entry of [...facts.hitl].sort((left, right) => hitlPriority(left) - hitlPriority(right) || Date.parse(left.view.createdAt) - Date.parse(right.view.createdAt))) { + if (attentionRootIds.has(entry.rootSessionId)) continue; + attentionRootIds.add(entry.rootSessionId); + const inspection = entry.view.requiresInspection === true; + needsYou.push({ + kind: "hitl", + project, + entityId: entry.hitlId, + title: inspection ? "Needs manual inspection" : entry.view.displayPayload.title, + status: inspection ? "inspection" : entry.view.source.type === "tool_permission" ? "permission" : "question", + href: sessionHref(facts.project.slug, entry.rootSessionId, hitlSearch(entry)), + sortAt: Date.parse(entry.view.createdAt), + ...(entry.view.displayPayload.summary === undefined ? {} : { context: entry.view.displayPayload.summary }), + }); + } + + for (const item of facts.sessions) { + const goal = item.session.goal; + if (attentionRootIds.has(item.session.sessionId) || (goal?.status !== "blocked" && goal?.status !== "budget_limited")) continue; + attentionRootIds.add(item.session.sessionId); + needsYou.push({ + kind: "session", + project, + entityId: item.session.sessionId, + title: item.session.title ?? "Untitled session", + status: goal.status, + href: sessionHref(facts.project.slug, item.session.sessionId), + sortAt: goal.updatedAt, + context: goal.blockedReason ?? goal.objective, + }); + } + + for (const item of facts.sessions) { + const execution = item.latestExecution; + if (attentionRootIds.has(item.session.sessionId) || execution === null || (execution.status !== "failed" && execution.status !== "timed_out")) continue; + attentionRootIds.add(item.session.sessionId); + needsYou.push({ + kind: "session", + project, + entityId: item.session.sessionId, + title: item.session.title ?? "Untitled session", + status: execution.status, + href: sessionHref(facts.project.slug, item.session.sessionId), + sortAt: execution.endedAt ?? execution.startedAt, + }); + } + + for (const item of facts.automations) { + const invocation = item.latestInvocation; + if (invocation === null || (invocation.status !== "failed" && invocation.status !== "missed")) continue; + attentionAutomationIds.add(item.automation.id); + needsYou.push({ + kind: "automation", + project, + entityId: item.automation.id, + title: item.automation.name, + status: invocation.status, + href: automationHref(facts.project.slug, item.automation.id, invocation.id), + sortAt: Date.parse(invocation.completedAt ?? invocation.createdAt), + }); + } + + const running = [...facts.activities].flatMap(([rootSessionId, activity]): HomeSummaryItem[] => { + if (attentionRootIds.has(rootSessionId)) return []; + const item = sessionsById.get(rootSessionId); + if (item === undefined) return []; + return [{ + kind: "session", + project, + entityId: rootSessionId, + title: item.session.title ?? "Untitled session", + status: activity, + href: sessionHref(facts.project.slug, rootSessionId), + sortAt: item.session.updatedAt, + context: sourceContext(item.session), + }]; + }); + + const readyToReview = facts.todos.flatMap((todo): HomeSummaryItem[] => { + if (todo.archivedAt !== undefined || todo.status !== "in_progress") return []; + const workSessions = facts.sessions + .filter((item) => item.session.source.kind === "todo" && item.session.source.todoId === todo.id && item.session.source.entry === "work") + .sort((left, right) => right.session.updatedAt - left.session.updatedAt || right.session.sessionId.localeCompare(left.session.sessionId)); + if (workSessions.length === 0) return []; + if (workSessions.some((item) => facts.activities.has(item.session.sessionId) + || hitlRootIds.has(item.session.sessionId) + || attentionRootIds.has(item.session.sessionId))) return []; + const latest = workSessions[0]!; + if (latest.latestExecution?.status !== "completed") return []; + return [{ + kind: "todo", + project, + entityId: todo.id, + title: todo.title, + status: "ready_to_review", + href: todoHref(facts.project.slug, todo.id), + sortAt: Math.max(todo.updatedAt, latest.session.updatedAt), + context: latest.session.title ?? "Completed work Session", + }]; + }); + + const upcoming = facts.automations.flatMap((item): HomeSummaryItem[] => { + if (attentionAutomationIds.has(item.automation.id) || item.automation.status !== "active" || item.automation.nextFireAt === undefined) return []; + return [{ + kind: "automation", + project, + entityId: item.automation.id, + title: item.automation.name, + status: "scheduled", + href: automationHref(facts.project.slug, item.automation.id), + sortAt: Date.parse(item.automation.nextFireAt), + context: item.automation.nextFireAt, + }]; + }); + + return { needsYou, running, readyToReview, upcoming }; +} + +function searchProject( + query: string, + facts: { project: ProjectInfo; sessions: readonly RootSessionSummary[]; automations: readonly Automation[]; todos: readonly ProjectTodo[] }, +): WorkSearchResult[] { + const needle = query.toLocaleLowerCase(); + const project = { slug: facts.project.slug, name: facts.project.name }; + const results: WorkSearchResult[] = []; + if (matches(needle, facts.project.name, facts.project.slug)) { + results.push({ kind: "project", project, entityId: facts.project.slug, title: facts.project.name, href: projectHref(facts.project.slug) }); + } + for (const todo of facts.todos) { + if (!matches(needle, todo.id, todo.title, todo.body)) continue; + results.push({ kind: "todo", project, entityId: todo.id, title: todo.title, href: todoHref(facts.project.slug, todo.id), context: todo.status }); + } + for (const session of facts.sessions) { + if (!matches(needle, session.sessionId, session.title, session.source.kind, JSON.stringify(session.source))) continue; + results.push({ kind: "session", project, entityId: session.sessionId, title: session.title ?? "Untitled session", href: sessionHref(facts.project.slug, session.sessionId), context: sourceContext(session) }); + } + for (const automation of facts.automations) { + if (!matches(needle, automation.id, automation.name, automation.action.message, JSON.stringify(automation.trigger), JSON.stringify(automation.origin))) continue; + results.push({ kind: "automation", project, entityId: automation.id, title: automation.name, href: automationHref(facts.project.slug, automation.id), context: automation.status }); + } + return results; +} + +function requireRootSummary(summary: SessionSummary): RootSessionSummary { + if (summary.parentSessionId !== undefined || summary.rootSessionId !== summary.sessionId || summary.source === undefined) { + throw new Error(`Invalid root Session summary: ${summary.sessionId}`); + } + return summary as RootSessionSummary; +} + +function sourceContext(session: RootSessionSummary): string { + switch (session.source.kind) { + case "direct": return session.agentName; + case "todo": return `Todo ${session.source.todoId}`; + case "automation": return `Automation ${session.source.automationId}`; + } +} + +function matches(needle: string, ...values: Array): boolean { + return values.some((value) => value?.toLocaleLowerCase().includes(needle) === true); +} + +function compareNeedsYou(left: HomeSummaryItem, right: HomeSummaryItem): number { + const priority = needsYouPriority(left) - needsYouPriority(right); + return priority || compareNewestFirst(left, right); +} + +function needsYouPriority(item: HomeSummaryItem): number { + if (item.kind === "hitl") return item.status === "inspection" ? 0 : 1; + if (item.kind === "session" && (item.status === "blocked" || item.status === "budget_limited")) return 2; + if (item.kind === "session") return 3; + return 4; +} + +function compareNewestFirst(left: HomeSummaryItem, right: HomeSummaryItem): number { + return right.sortAt - left.sortAt || left.href.localeCompare(right.href); +} + +function hitlPriority(entry: GlobalSSEHitlEntry): number { + return entry.view.requiresInspection === true ? 0 : 1; +} + +function hitlSearch(entry: GlobalSSEHitlEntry): URLSearchParams { + const search = new URLSearchParams({ hitl: entry.hitlId }); + if (entry.ownerSessionId !== entry.rootSessionId) search.set("focus", entry.ownerSessionId); + return search; +} + +function projectHref(projectSlug: string): string { + return `/projects/${encodeURIComponent(projectSlug)}/todos`; +} + +function todoHref(projectSlug: string, todoId: string): string { + return `${projectHref(projectSlug)}?todo=${encodeURIComponent(todoId)}`; +} + +function sessionHref(projectSlug: string, sessionId: string, search?: URLSearchParams): string { + const href = `/projects/${encodeURIComponent(projectSlug)}/sessions/${encodeURIComponent(sessionId)}`; + return search === undefined ? href : `${href}?${search.toString()}`; +} + +function automationHref(projectSlug: string, automationId: string, invocationId?: string): string { + const href = `/projects/${encodeURIComponent(projectSlug)}/automations/${encodeURIComponent(automationId)}`; + return invocationId === undefined ? href : `${href}?invocation=${encodeURIComponent(invocationId)}`; +} + +function toProjectError(project: ProjectInfo, error: unknown): WorkbenchProjectReadError { + return { + project: { slug: project.slug, name: project.name }, + message: error instanceof Error ? error.message : String(error), + }; +} diff --git a/apps/server/src/project-inventory-read.ts b/apps/server/src/project-inventory-read.ts new file mode 100644 index 00000000..8b8d6f91 --- /dev/null +++ b/apps/server/src/project-inventory-read.ts @@ -0,0 +1,33 @@ +import type { AgentRuntime } from "@archcode/agent-core"; +import type { + LatestExecutionDigest, + ProjectAutomationInventoryItem, + ProjectSessionInventoryItem, + SessionExecutionRecord, +} from "@archcode/protocol"; + +export async function readProjectSessionInventory( + runtime: AgentRuntime, + workspaceRoot: string, +): Promise { + return await runtime.listSessionInventory(workspaceRoot); +} + +export async function readProjectAutomationInventory( + runtime: AgentRuntime, + workspaceRoot: string, +): Promise { + return await runtime.listAutomationInventory(workspaceRoot); +} + +export function toLatestExecutionDigest( + execution: SessionExecutionRecord | undefined, +): LatestExecutionDigest | null { + if (execution === undefined) return null; + return { + id: execution.id, + status: execution.status, + startedAt: execution.startedAt, + ...(execution.endedAt === undefined ? {} : { endedAt: execution.endedAt }), + }; +} diff --git a/apps/server/src/routes/automations.test.ts b/apps/server/src/routes/automations.test.ts index a5c37d16..365d921c 100644 --- a/apps/server/src/routes/automations.test.ts +++ b/apps/server/src/routes/automations.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdir, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { ProjectRegistry, silentLogger } from "@archcode/agent-core"; -import type { Automation, AutomationInvocation } from "@archcode/protocol"; +import type { Automation, AutomationInvocation, ProjectAutomationInventoryItem } from "@archcode/protocol"; import { errorHandler } from "../error-handler"; import { createAutomationsRoutes } from "./automations"; @@ -12,7 +12,7 @@ function automation(): Automation { return { id: "11111111-1111-4111-8111-111111111111", projectSlug: "project", - createdFromSessionId: "44444444-4444-4444-8444-444444444444", + origin: { kind: "direct" }, name: "Daily check", status: "active", trigger: { kind: "cron", expression: "0 9 * * 1", timezone: "Asia/Shanghai" }, @@ -33,7 +33,9 @@ async function fixture(name: string) { const runtime = { projectRegistry, listAutomations: mock(async () => [item]), + listAutomationInventory: mock(async (): Promise => [{ automation: item, latestInvocation: null }]), readAutomation: mock(async () => item), + createDirectAutomation: mock(async (_root: string, input: Pick) => ({ ...item, ...input })), updateAutomation: mock(async (_root: string, _id: string, input: Partial>) => ({ ...item, ...input })), deleteAutomation: mock(async () => undefined), pauseAutomation: mock(async () => ({ ...item, status: "paused" as const })), @@ -45,7 +47,7 @@ async function fixture(name: string) { status: "pending", createdAt: "2026-07-13T00:00:00.000Z", })), - listAutomationInvocations: mock(async () => []), + listAutomationInvocations: mock(async (): Promise => []), }; const app = createAutomationsRoutes(runtime as unknown as Parameters[0]); app.onError(errorHandler); @@ -71,18 +73,60 @@ describe("automation routes", () => { expect(runtime.runAutomationNow).toHaveBeenCalledWith(project.workspaceRoot, item.id); }); - test("rejects provenance changes through the update route", async () => { + test("creates a direct Automation while keeping origin server-owned", async () => { + const { app, item, project, runtime } = await fixture("direct-create"); + const input = { name: item.name, trigger: item.trigger, action: item.action }; + const response = await app.request(`/${project.slug}/automations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ automation: item }); + expect(runtime.createDirectAutomation).toHaveBeenCalledWith(project.workspaceRoot, input); + + const forged = await app.request(`/${project.slug}/automations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...input, origin: { kind: "session", sessionId: crypto.randomUUID() } }), + }); + expect(forged.status).toBe(400); + expect(runtime.createDirectAutomation).toHaveBeenCalledTimes(1); + }); + + test("rejects origin changes through the update route", async () => { const { app, item, project, runtime } = await fixture("immutable-provenance"); const res = await app.request(`/${project.slug}/automations/${item.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ createdFromSessionId: crypto.randomUUID() }), + body: JSON.stringify({ origin: { kind: "direct" } }), }); expect(res.status).toBe(400); expect(runtime.updateAutomation).not.toHaveBeenCalled(); }); + test("lists each Automation with its complete latest Invocation snapshot", async () => { + const { app, item, project, runtime } = await fixture("inventory"); + const latest: AutomationInvocation = { + id: "33333333-3333-4333-8333-333333333333", + automationId: item.id, + dueAt: "2026-07-13T00:00:00.000Z", + status: "failed", + createdAt: "2026-07-13T00:00:00.000Z", + completedAt: "2026-07-13T00:01:00.000Z", + error: "dispatch failed", + }; + runtime.listAutomationInventory.mockResolvedValueOnce([{ automation: item, latestInvocation: latest }]); + + const response = await app.request(`/${project.slug}/automations`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ automations: [{ automation: item, latestInvocation: latest }] }); + expect(runtime.listAutomationInventory).toHaveBeenCalledWith(project.workspaceRoot); + }); + test("rejects malformed JSON, invalid IDs, and invalid invocation limits", async () => { const { app, item, project, runtime } = await fixture("validation"); const malformed = await app.request(`/${project.slug}/automations/${item.id}`, { diff --git a/apps/server/src/routes/automations.ts b/apps/server/src/routes/automations.ts index d39cf7e6..2c86e4b3 100644 --- a/apps/server/src/routes/automations.ts +++ b/apps/server/src/routes/automations.ts @@ -1,13 +1,15 @@ import { Hono } from "hono"; import { + AutomationCreateSchema, AutomationUpdateSchema, type AgentRuntime, } from "@archcode/agent-core"; -import type { Automation } from "@archcode/protocol"; +import type { Automation, ProjectAutomationInventoryResponse } from "@archcode/protocol"; import { z } from "zod/v4"; import { BadRequestError, ServerError } from "../errors"; import { resolveProject } from "../resolve"; import { zValidator } from "../validation"; +import { readProjectAutomationInventory } from "../project-inventory-read"; const AutomationIdSchema = z.uuid(); const AutomationListParamsSchema = z.strictObject({ slug: z.string().min(1) }); @@ -28,9 +30,23 @@ export function createAutomationsRoutes(runtime: AgentRuntime): Hono { app.get("/:slug/automations", zValidator("param", AutomationListParamsSchema), async (c) => { const project = await resolveProject(runtime, c.req.valid("param").slug); - return c.json({ automations: await runtime.listAutomations(project.workspaceRoot) }); + return c.json({ + automations: await readProjectAutomationInventory(runtime, project.workspaceRoot), + } satisfies ProjectAutomationInventoryResponse); }); + app.post( + "/:slug/automations", + zValidator("param", AutomationListParamsSchema), + zValidator("json", AutomationCreateSchema), + async (c) => { + const project = await resolveProject(runtime, c.req.valid("param").slug); + return c.json({ + automation: await runtime.createDirectAutomation(project.workspaceRoot, c.req.valid("json")), + }, 201); + }, + ); + app.get("/:slug/automations/:automationId", zValidator("param", AutomationParamsSchema), async (c) => { const { slug, automationId } = c.req.valid("param"); const project = await resolveProject(runtime, slug); diff --git a/apps/server/src/routes/dashboard.test.ts b/apps/server/src/routes/dashboard.test.ts deleted file mode 100644 index f78fe326..00000000 --- a/apps/server/src/routes/dashboard.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import { Hono } from "hono"; -import type { Automation, AutomationInvocation, SessionExecutionRecord, SessionExecutionTerminalStatus, SessionGoal, SessionSummary } from "@archcode/protocol"; -import { createDashboardRoutes } from "./dashboard"; - -const workspaceRoot = process.cwd(); - -function goal(status: SessionGoal["status"] = "blocked"): SessionGoal { - return { - instanceId: "goal", generation: 1, objective: "Finish the migration and run all tests.", status, - usage: { tokens: { inputTokens: 10, outputTokens: 20, totalTokens: 30, reasoningTokens: 0, cachedInputTokens: 0 }, executionTimeMs: 90_000, executionCount: 2 }, - settlementReceipts: [], - createdAt: 1, activatedAt: 1, updatedAt: 2, - }; -} - -function rootSummary(sessionId = "root"): SessionSummary { - return { - sessionId, cwd: workspaceRoot, rootSessionId: sessionId, agentName: "lead", profile: "principal", activeSkillNames: [], - modelSelection: { revision: 0 }, title: "Migration", goal: goal(), createdAt: 1, updatedAt: 2, - }; -} - -function execution(status: SessionExecutionTerminalStatus | "running" = "failed"): SessionExecutionRecord { - const binding = { - selection: { model: "test:model" }, providerId: "test", modelId: "model", - providerDisplayName: "Test", modelDisplayName: "Model", resolution: "profile_default" as const, modelRuntimeRevision: "test-revision", - }; - const usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedInputTokens: 0 }; - if (status === "running") { - return { - id: "execution-1", status, startedAt: 10, durationMs: 0, maxSteps: 50, origin: "user_message", - runs: [{ ordinal: 0, startedAt: 10, binding }], - }; - } - return { - id: "execution-1", status, startedAt: 10, endedAt: 20, - durationMs: 10, maxSteps: 50, origin: "user_message", - runs: [{ ordinal: 0, startedAt: 10, endedAt: 20, durationMs: 10, binding, usageDelta: usage, settlement: { key: "run:root:execution-1:0", goalInstanceId: null } }], - terminalSettlement: { key: "terminal:root:execution-1", goalInstanceId: null }, - }; -} - -function automation(id = "automation-1"): Automation { - return { - id, projectSlug: "demo", createdFromSessionId: "root", name: "Deploy", status: "active", - trigger: { kind: "interval", everyMs: 60_000 }, action: { kind: "start_session", message: "run", location: "project" }, - createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T01:00:00.000Z", nextFireAt: "2026-01-01T02:00:00.000Z", - }; -} - -function invocation(): AutomationInvocation { - return { - id: "invocation-1", automationId: "automation-1", dueAt: "2026-01-01T00:00:00.000Z", status: "failed", - sessionId: "root", createdAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:01:00.000Z", error: "secret failure detail", - }; -} - -function runtime(overrides: Record = {}) { - const project = { slug: "demo", name: "Demo", workspaceRoot, addedAt: "now" }; - return { - projectRegistry: { - get: mock(async (slug: string) => slug === project.slug ? project : undefined), - list: mock(async () => [project]), - }, - listSessions: mock(async () => [rootSummary(), { ...rootSummary("child"), parentSessionId: "root", rootSessionId: "root", agentName: "analyst" }]), - getSessionFile: mock(async () => ({ ...rootSummary(), executions: [execution()] })), - listAutomations: mock(async () => [automation()]), - listAutomationInvocations: mock(async () => [invocation()]), - ...overrides, - } as unknown as Parameters[0]; -} - -describe("DashboardProjection routes", () => { - test("uses one global read model rooted in Session and Automation owners", async () => { - const app = new Hono().route("/api", createDashboardRoutes(runtime())); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - scope: { kind: "global" }, - sessions: [{ - projectSlug: "demo", projectName: "Demo", rootSessionId: "root", sessionTitle: "Migration", createdAt: 1, updatedAt: 2, - goal: goal(), latestExecution: { id: "execution-1", status: "failed", startedAt: 10, endedAt: 20 }, - }], - automations: [{ - projectSlug: "demo", projectName: "Demo", id: "automation-1", name: "Deploy", status: "active", - createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T01:00:00.000Z", nextFireAt: "2026-01-01T02:00:00.000Z", - latestInvocation: { id: "invocation-1", status: "failed", sessionId: "root", createdAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:01:00.000Z" }, - }], - errors: [], - }); - }); - - test("uses the same projection contract for one project", async () => { - const app = new Hono().route("/api", createDashboardRoutes(runtime())); - - const response = await app.request("/api/projects/demo/dashboard"); - - expect(response.status).toBe(200); - expect((await response.json() as { scope: unknown }).scope).toEqual({ kind: "project", projectSlug: "demo" }); - }); - - test("isolates a corrupt project while preserving other global project rows", async () => { - const good = { slug: "good", name: "Good", workspaceRoot, addedAt: "now" }; - const bad = { slug: "bad", name: "Bad", workspaceRoot: "/bad", addedAt: "now" }; - const app = new Hono().route("/api", createDashboardRoutes(runtime({ - projectRegistry: { get: mock(async (slug: string) => slug === "good" ? good : slug === "bad" ? bad : undefined), list: mock(async () => [good, bad]) }, - listSessions: mock(async (root: string) => root === "/bad" ? Promise.reject(new Error("session file is corrupt")) : [rootSummary()]), - }))); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - sessions: [{ projectSlug: "good" }], - errors: [{ projectSlug: "bad", projectName: "Bad", message: "session file is corrupt" }], - }); - }); - - test("uses only latest Session and Invocation records so recovered work leaves attention", async () => { - const app = new Hono().route("/api", createDashboardRoutes(runtime({ - getSessionFile: mock(async () => ({ - ...rootSummary(), - executions: [execution("failed"), { - ...execution("running"), - id: "execution-2", - startedAt: 30, - runs: [{ ordinal: 0, startedAt: 30, binding: { - selection: { model: "test:model" }, providerId: "test", modelId: "model", - providerDisplayName: "Test", modelDisplayName: "Model", resolution: "profile_default", modelRuntimeRevision: "test-revision", - } }], - }], - })), - listAutomationInvocations: mock(async () => [ - invocation(), - { ...invocation(), id: "invocation-2", status: "dispatched", createdAt: "2026-01-01T00:02:00.000Z" }, - ]), - }))); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - sessions: [{ latestExecution: { id: "execution-2", status: "running" } }], - automations: [{ latestInvocation: { id: "invocation-2", status: "dispatched" } }], - }); - }); - - test("isolates an Automation projection failure without dropping a healthy project", async () => { - const good = { slug: "good", name: "Good", workspaceRoot, addedAt: "now" }; - const bad = { slug: "bad", name: "Bad", workspaceRoot: "/bad", addedAt: "now" }; - const app = new Hono().route("/api", createDashboardRoutes(runtime({ - projectRegistry: { - get: mock(async (slug: string) => slug === "good" ? good : slug === "bad" ? bad : undefined), - list: mock(async () => [good, bad]), - }, - listSessions: mock(async () => [rootSummary()]), - listAutomations: mock(async (root: string) => root === "/bad" ? [automation("bad-automation")] : [automation("good-automation")]), - listAutomationInvocations: mock(async (root: string) => { - if (root === "/bad") throw new Error("automation invocation is corrupt"); - return [invocation()]; - }), - }))); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - sessions: [{ projectSlug: "good" }], - automations: [{ projectSlug: "good", id: "good-automation" }], - errors: [{ projectSlug: "bad", projectName: "Bad", message: "automation invocation is corrupt" }], - }); - }); -}); diff --git a/apps/server/src/routes/dashboard.ts b/apps/server/src/routes/dashboard.ts deleted file mode 100644 index d5c87609..00000000 --- a/apps/server/src/routes/dashboard.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Hono } from "hono"; -import type { AgentRuntime } from "@archcode/agent-core"; -import { z } from "zod/v4"; -import { DashboardProjectionService } from "../dashboard-projection-service"; -import { ProjectNotFoundError } from "../errors"; -import { zValidator } from "../validation"; - -const ProjectDashboardParamsSchema = z.strictObject({ slug: z.string().min(1) }); - -/** - * The Dashboard has one read-model contract. Scope is selected by endpoint, - * never by a client-supplied switch that could accidentally broaden access. - */ -export function createDashboardRoutes(runtime: AgentRuntime): Hono { - const app = new Hono(); - const projection = new DashboardProjectionService(runtime); - - app.get("/dashboard", async (c) => c.json(await projection.read({ kind: "global" }))); - app.get( - "/projects/:slug/dashboard", - zValidator("param", ProjectDashboardParamsSchema), - async (c) => { - const { slug } = c.req.valid("param"); - if (await runtime.projectRegistry.get(slug) === undefined) throw new ProjectNotFoundError(slug); - return c.json(await projection.read({ kind: "project", projectSlug: slug })); - }, - ); - - return app; -} diff --git a/apps/server/src/routes/global-work.test.ts b/apps/server/src/routes/global-work.test.ts new file mode 100644 index 00000000..dd94b830 --- /dev/null +++ b/apps/server/src/routes/global-work.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, mock, test } from "bun:test"; +import { Hono } from "hono"; +import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; +import type { + Automation, + AutomationInvocation, + GlobalSSEHitlEntry, + ProjectTodo, + RootSessionSummary, + SessionExecutionRecord, +} from "@archcode/protocol"; + +import { errorHandler } from "../error-handler"; +import { createGlobalWorkRoutes } from "./global-work"; + +const goodProject: ProjectInfo = { + slug: "good", + name: "Good Project", + workspaceRoot: "/projects/good", + addedAt: "2026-08-01T00:00:00.000Z", +}; +const badProject: ProjectInfo = { + slug: "bad", + name: "Bad Project", + workspaceRoot: "/projects/bad", + addedAt: "2026-08-01T00:00:00.000Z", +}; + +describe("global work read routes", () => { + test("GET /api/home returns server-grouped attention, running, review, and upcoming rows", async () => { + const todo = makeTodo(); + const work = rootSession("work", { kind: "todo", todoId: todo.id, entry: "work" }, 50); + const hitlRoot = rootSession("hitl-root", { kind: "direct" }, 40); + const failure = rootSession("failure", { kind: "direct" }, 30); + const automation = makeAutomation(); + const failedAutomation = makeAutomation({ + id: "33333333-3333-4333-8333-333333333333", + name: "Failing review", + }); + const runtime = makeRuntime({ + projects: [goodProject, badProject], + sessions: [work, hitlRoot, failure], + todos: [todo], + automations: [automation, failedAutomation], + automationInvocations: new Map([[failedAutomation.id, { + id: "44444444-4444-4444-8444-444444444444", + automationId: failedAutomation.id, + dueAt: "2026-08-03T00:00:00.000Z", + status: "failed", + createdAt: "2026-08-03T00:00:00.000Z", + completedAt: "2026-08-03T00:01:00.000Z", + error: "model unavailable", + }]]), + executions: new Map([ + [work.sessionId, [execution("completed", 60)]], + [hitlRoot.sessionId, [execution("running", 41)]], + [failure.sessionId, [execution("failed", 31)]], + ]), + hitl: [hitlEntry(hitlRoot.sessionId)], + activities: new Map([[hitlRoot.sessionId, "running"]]), + failBadProject: true, + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const response = await app.request("/api/home"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + needsYou: [ + { kind: "hitl", entityId: "hitl-1", status: "question", project: { slug: "good" } }, + { kind: "session", entityId: "failure", status: "failed", project: { slug: "good" } }, + { kind: "automation", entityId: failedAutomation.id, status: "failed", project: { slug: "good" } }, + ], + running: [], + readyToReview: [{ kind: "todo", entityId: todo.id, status: "ready_to_review" }], + upcoming: [{ kind: "automation", entityId: automation.id, status: "scheduled" }], + projectErrors: [{ project: { slug: "bad", name: "Bad Project" }, message: "corrupt project" }], + }); + expect(JSON.stringify(body)).not.toContain(todo.body); + }); + + test("a Goal-attention Session excludes its entire Todo work family from Ready to review", async () => { + const todo = makeTodo(); + const blockedWork: RootSessionSummary = { + ...rootSession("blocked-work", { kind: "todo", todoId: todo.id, entry: "work" }, 40), + goal: { + instanceId: "goal-1", + generation: 1, + objective: "Finish the Todo", + status: "blocked", + usage: { + tokens: { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedInputTokens: 0 }, + executionTimeMs: 0, + executionCount: 0, + }, + settlementReceipts: [], + blockedReason: "Needs a decision", + createdAt: 1, + activatedAt: 1, + updatedAt: 50, + }, + }; + const completedWork = rootSession("completed-work", { kind: "todo", todoId: todo.id, entry: "work" }, 60); + const runtime = makeRuntime({ + projects: [goodProject], + sessions: [blockedWork, completedWork], + todos: [todo], + automations: [], + executions: new Map([[completedWork.sessionId, [execution("completed", 61)]]]), + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const response = await app.request("/api/home"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.needsYou).toEqual([ + expect.objectContaining({ entityId: blockedWork.sessionId, status: "blocked" }), + ]); + expect(body.readyToReview).toEqual([]); + }); + + test("GET /api/search matches hidden Todo body, caps at 100, and isolates project failures", async () => { + const todos = Array.from({ length: 101 }, (_, index) => makeTodo({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + title: `Todo ${index}`, + body: "hidden needle body", + })); + const runtime = makeRuntime({ + projects: [goodProject, badProject], + sessions: [], + todos, + automations: [], + executions: new Map(), + failBadProject: true, + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const response = await app.request("/api/search?q=%20needle%20"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.results).toHaveLength(100); + expect(body.truncated).toBe(true); + expect(body.projectErrors).toEqual([{ project: { slug: "bad", name: "Bad Project" }, message: "corrupt project" }]); + expect(body.results[0]).toEqual({ + kind: "todo", + project: { slug: "good", name: "Good Project" }, + entityId: todos[0]!.id, + title: "Todo 0", + href: `/projects/good/todos?todo=${todos[0]!.id}`, + context: "in_progress", + }); + expect(body.results[0]).not.toHaveProperty("body"); + }); + + test("GET /api/search enforces the trimmed 1-200 character boundary", async () => { + const runtime = makeRuntime({ projects: [], sessions: [], todos: [], automations: [], executions: new Map() }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const empty = await app.request("/api/search?q=%20%20"); + const twoHundred = await app.request(`/api/search?q=${"a".repeat(200)}`); + const twoHundredOne = await app.request(`/api/search?q=${"a".repeat(201)}`); + + expect(empty.status).toBe(400); + expect(twoHundred.status).toBe(200); + expect(twoHundredOne.status).toBe(400); + }); +}); + +function makeRuntime(input: { + projects: ProjectInfo[]; + sessions: RootSessionSummary[]; + todos: ProjectTodo[]; + automations: Automation[]; + executions: Map; + automationInvocations?: Map; + hitl?: GlobalSSEHitlEntry[]; + activities?: Map; + failBadProject?: boolean; +}): AgentRuntime { + return { + projectRegistry: { list: mock(async () => input.projects) }, + listSessions: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.sessions; + }), + listSessionInventory: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.sessions.map((session) => { + const latest = input.executions.get(session.sessionId)?.at(-1); + return { + session, + latestExecution: latest === undefined + ? null + : { + id: latest.id, + status: latest.status, + startedAt: latest.startedAt, + ...("endedAt" in latest ? { endedAt: latest.endedAt } : {}), + }, + }; + }); + }), + getSessionFile: mock(async (_workspaceRoot: string, sessionId: string) => ({ + ...input.sessions.find((session) => session.sessionId === sessionId)!, + executions: input.executions.get(sessionId) ?? [], + })), + listAutomations: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.automations; + }), + listAutomationInventory: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.automations.map((automation) => ({ + automation, + latestInvocation: input.automationInvocations?.get(automation.id) ?? null, + })); + }), + listAutomationInvocations: mock(async () => []), + contextResolver: { resolve: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return { todos: { listTodos: mock(async () => input.todos) } }; + }) }, + getProjectControlPlaneSnapshot: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return { + sessionRuntime: { + type: "session.runtime.snapshot", + projectSlugs: [goodProject.slug], + families: [...(input.activities ?? new Map())].map(([rootSessionId, activity]) => ({ + projectSlug: goodProject.slug, + rootSessionId, + activity, + })), + createdAt: 1, + }, + hitl: { + type: "hitl.snapshot", + projectSlugs: [goodProject.slug], + entries: input.hitl ?? [], + createdAt: 1, + }, + }; + }), + } as unknown as AgentRuntime; +} + +function rootSession(sessionId: string, source: RootSessionSummary["source"], updatedAt: number): RootSessionSummary { + return { + sessionId, + cwd: goodProject.workspaceRoot, + rootSessionId: sessionId, + agentName: "lead", + profile: "principal", + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: sessionId, + source, + createdAt: 1, + updatedAt, + }; +} + +function execution(status: "running" | "completed" | "failed", startedAt: number): SessionExecutionRecord { + return { + id: `execution-${startedAt}`, + status, + startedAt, + ...(status === "running" ? {} : { endedAt: startedAt + 1 }), + } as SessionExecutionRecord; +} + +function makeTodo(overrides: Partial = {}): ProjectTodo { + return { + id: "11111111-1111-4111-8111-111111111111", + title: "Review the completed work", + body: "Private PRD body", + status: "in_progress", + revision: 1, + createdAt: 1, + updatedAt: 70, + ...overrides, + }; +} + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: "22222222-2222-4222-8222-222222222222", + projectSlug: goodProject.slug, + origin: { kind: "direct" }, + name: "Nightly review", + trigger: { kind: "cron", expression: "0 1 * * *", timezone: "UTC" }, + action: { kind: "start_session", message: "Review", location: "project" }, + status: "active", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + nextFireAt: "2026-08-04T01:00:00.000Z", + ...overrides, + }; +} + +function hitlEntry(rootSessionId: string): GlobalSSEHitlEntry { + return { + projectSlug: goodProject.slug, + hitlId: "hitl-1", + ownerSessionId: rootSessionId, + rootSessionId, + view: { + hitlId: "hitl-1", + owner: { type: "session", id: rootSessionId }, + source: { type: "ask_user", toolCallId: "question-1" }, + status: "pending", + displayPayload: { title: "Choose an option", summary: "Waiting for an answer", redacted: true }, + allowedActions: ["answer", "cancel"], + createdAt: "2026-08-03T00:00:00.000Z", + updatedAt: "2026-08-03T00:00:00.000Z", + }, + }; +} diff --git a/apps/server/src/routes/global-work.ts b/apps/server/src/routes/global-work.ts new file mode 100644 index 00000000..1b672da0 --- /dev/null +++ b/apps/server/src/routes/global-work.ts @@ -0,0 +1,22 @@ +import type { AgentRuntime } from "@archcode/agent-core"; +import { Hono } from "hono"; +import { z } from "zod/v4"; + +import { GlobalWorkReadService } from "../global-work-read-service"; +import { zValidator } from "../validation"; + +const GlobalSearchQuerySchema = z.strictObject({ + q: z.string().trim().min(1).max(200), +}); + +export function createGlobalWorkRoutes(runtime: AgentRuntime): Hono { + const app = new Hono(); + const reads = new GlobalWorkReadService(runtime); + + app.get("/home", async (c) => c.json(await reads.readHome())); + app.get("/search", zValidator("query", GlobalSearchQuerySchema), async (c) => ( + c.json(await reads.search(c.req.valid("query").q)) + )); + + return app; +} diff --git a/apps/server/src/routes/sessions.test.ts b/apps/server/src/routes/sessions.test.ts index 5a830048..2e449b83 100644 --- a/apps/server/src/routes/sessions.test.ts +++ b/apps/server/src/routes/sessions.test.ts @@ -9,12 +9,16 @@ const tempRoot = resolve(import.meta.dir, "__test_tmp__", "sessions-routes"); interface SessionSummaryBody { sessions: Array<{ - sessionId: string; - rootSessionId: string; - parentSessionId?: string; - title?: string | null; - createdAt: number; - updatedAt: number; + session: { + sessionId: string; + rootSessionId: string; + parentSessionId?: string; + title?: string | null; + source: { kind: "direct" }; + createdAt: number; + updatedAt: number; + }; + latestExecution: null | { id: string; status: string; startedAt: number; endedAt?: number }; }>; } @@ -36,6 +40,8 @@ type StoredSessionBody = SessionFileBody & { reminders: unknown[]; rootSessionId: string; parentSessionId?: string; + source?: { kind: "direct" }; + executions: Array<{ id: string; status: string; startedAt: number; endedAt?: number }>; }; class MissingSessionFileError extends Error { @@ -62,6 +68,8 @@ function createStoredSession(input: { reminders: [], rootSessionId: input.rootSessionId ?? sessionId, ...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}), + ...(input.parentSessionId === undefined ? { source: { kind: "direct" as const } } : {}), + executions: [], }; } @@ -114,11 +122,35 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { rootSessionId: session.rootSessionId, ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), title: session.title, + ...(session.source === undefined ? {} : { source: session.source }), createdAt: session.createdAt, updatedAt: session.updatedAt, })) .sort((a, b) => b.createdAt - a.createdAt); }, + listSessionInventory: async (workspaceRoot: string) => { + calls.listSessions += 1; + return [...sessions.entries()] + .filter(([key]) => key.startsWith(`${workspaceRoot}\0`)) + .filter(([, session]) => session.parentSessionId === undefined) + .map(([, session]) => ({ + session: { + sessionId: session.sessionId, + cwd: workspaceRoot, + rootSessionId: session.rootSessionId, + agentName: "lead", + profile: "principal", + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: session.title, + source: session.source!, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }, + latestExecution: session.executions.at(-1) ?? null, + })) + .sort((a, b) => b.session.updatedAt - a.session.updatedAt); + }, getSessionModelState: async (workspaceRoot: string, sessionId: string) => { const key = `${workspaceRoot}\0${sessionId}`; if (!sessions.has(key)) throw new MissingSessionFileError(); @@ -416,15 +448,17 @@ describe("sessions routes", () => { const body = (await res.json()) as SessionSummaryBody; expect(res.status).toBe(200); - expect(body.sessions).toEqual([ - { + expect(body.sessions).toEqual([{ + session: expect.objectContaining({ sessionId: session.sessionId, rootSessionId: session.sessionId, title: null, + source: { kind: "direct" }, createdAt: session.createdAt, updatedAt: session.updatedAt, - }, - ]); + }), + latestExecution: null, + }]); }); test("GET /api/projects/:slug/sessions/:sessionId returns full session data", async () => { @@ -617,7 +651,7 @@ describe("sessions routes", () => { const body = (await res.json()) as SessionSummaryBody; expect(res.status).toBe(200); - expect(body.sessions.map((session) => session.sessionId)).toEqual(["newer", "older"]); + expect(body.sessions.map((item) => item.session.sessionId)).toEqual(["newer", "older"]); }); test("GET /api/projects/:slug/sessions returns only root sessions with identity fields", async () => { @@ -629,15 +663,35 @@ describe("sessions routes", () => { const body = (await res.json()) as SessionSummaryBody; expect(res.status).toBe(200); - expect(body.sessions).toEqual([ - { + expect(body.sessions).toEqual([{ + session: expect.objectContaining({ sessionId: "root-session", rootSessionId: "root-session", title: "Root", + source: { kind: "direct" }, createdAt: 1_000, updatedAt: 1_000, - }, - ]); + }), + latestExecution: null, + }]); + }); + + test("GET /api/projects/:slug/sessions composes the latest Execution digest", async () => { + const { app, project, workspaceRoot, sessions, calls } = await createTestApp("session-execution-digest"); + const session = createStoredSession({ sessionId: "root-session", createdAt: 1_000, title: "Root" }); + session.executions.push( + { id: "execution-1", status: "failed", startedAt: 1_100, endedAt: 1_200 }, + { id: "execution-2", status: "completed", startedAt: 1_300, endedAt: 1_400 }, + ); + sessions.set(`${workspaceRoot}\0root-session`, session); + + const response = await app.request(`/api/projects/${project.slug}/sessions`); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + sessions: [{ latestExecution: { id: "execution-2", status: "completed", startedAt: 1_300, endedAt: 1_400 } }], + }); + expect(calls.getSessionFile).toBe(0); }); test("DELETE /api/projects/:slug/sessions/:sessionId delegates cleanup to runtime", async () => { diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index aa853082..a2171611 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -13,10 +13,12 @@ import { SessionModelSelectionNotAllowedError, } from "@archcode/agent-core"; import type { AgentRuntime } from "@archcode/agent-core"; +import type { ProjectSessionInventoryResponse } from "@archcode/protocol"; import { z } from "zod/v4"; import { BadRequestError, ConflictError, ServerError, SessionNotFoundError, SessionStopConflictHttpError } from "../errors"; import { resolveProject } from "../resolve"; import { zValidator } from "../validation"; +import { readProjectSessionInventory } from "../project-inventory-read"; const ProjectParamsSchema = z.strictObject({ slug: z.string().min(1) }); const SessionParamsSchema = z.strictObject({ @@ -51,15 +53,18 @@ export function createSessionsRoutes(runtime: AgentRuntime): Hono { app.get("/", zValidator("param", ProjectParamsSchema), async (c) => { const project = await resolveProject(runtime, c.req.valid("param").slug); - const sessions = await runtime.listSessions(project.workspaceRoot); + const sessions = await readProjectSessionInventory(runtime, project.workspaceRoot); - return c.json({ sessions }); + return c.json({ sessions } satisfies ProjectSessionInventoryResponse); }); app.post("/", zValidator("param", ProjectParamsSchema), async (c) => { await rejectRequestBody(c.req.text()); const project = await resolveProject(runtime, c.req.valid("param").slug); - return c.json(await runtime.createSession(project.workspaceRoot, { agentName: "lead" }), 201); + return c.json(await runtime.createSession(project.workspaceRoot, { + agentName: "lead", + source: { kind: "direct" }, + }), 201); }); app.get("/:sessionId", zValidator("param", SessionParamsSchema), async (c) => { diff --git a/apps/server/src/routes/todos.test.ts b/apps/server/src/routes/todos.test.ts index 3d2b0cf9..ad5df59f 100644 --- a/apps/server/src/routes/todos.test.ts +++ b/apps/server/src/routes/todos.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Hono } from "hono"; @@ -7,6 +7,7 @@ import type { CreateProjectTodoSessionInput, ProjectTodo, ProjectTodoCreateInput, + ProjectTodoRunNowInput, ProjectTodoUpdateInput, } from "@archcode/protocol"; import type { AgentRuntime } from "@archcode/agent-core"; @@ -99,6 +100,29 @@ describe("Project Todo routes", () => { }); }); + test("runs a captured Todo through the single backend-owned command", async () => { + const todo = makeTodo({ status: "in_progress" }); + const fixture = createFixture(todo); + const input: ProjectTodoRunNowInput = { + clientRequestId: crypto.randomUUID(), + title: "Start now", + body: "Implement it.", + }; + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/run-now`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + todo, + session: { sessionId: "11111111-1111-4111-8111-111111111111", source: { kind: "todo", todoId: todo.id, entry: "work" } }, + }); + expect(fixture.runNow).toHaveBeenCalledWith(input); + }); + test("strictly validates flat mutation and Session request bodies", async () => { const fixture = createFixture(makeTodo()); const base = `/api/projects/${fixture.project.slug}/todos`; @@ -128,11 +152,49 @@ describe("Project Todo routes", () => { initialIntent: "plan", }), }); + const invalidRunNow = await fixture.app.request(`${base}/run-now`, { + method: "POST", + headers, + body: JSON.stringify({ clientRequestId: crypto.randomUUID(), title: "Start", origin: "forged" }), + }); expect(emptyMutation.status).toBe(400); expect(mixedArchive.status).toBe(400); expect(invalidEntry.status).toBe(400); expect(invalidPlanIntent.status).toBe(400); + expect(invalidRunNow.status).toBe(400); + expect(fixture.runNow).not.toHaveBeenCalled(); + }); + + test("maps Run now key conflicts and recovery-required failures", async () => { + const fixture = createFixture(makeTodo()); + const path = `/api/projects/${fixture.project.slug}/todos/run-now`; + const clientRequestId = crypto.randomUUID(); + const request = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clientRequestId, title: "Start" }), + }; + fixture.runNow.mockRejectedValueOnce(Object.assign(new Error("different input"), { + code: "PROJECT_TODO_RUN_NOW_CONFLICT", + clientRequestId, + })); + const conflict = await fixture.app.request(path, request); + fixture.runNow.mockRejectedValueOnce(Object.assign(new Error("manual recovery"), { + code: "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED", + todoId: fixture.todo.id, + sessionId: "11111111-1111-4111-8111-111111111111", + })); + const recovery = await fixture.app.request(path, request); + + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "PROJECT_TODO_RUN_NOW_CONFLICT", clientRequestId } }, + }); + expect(recovery.status).toBe(500); + expect(await recovery.json()).toMatchObject({ + error: { code: "INTERNAL_ERROR", details: { scopeCode: "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED", todoId: fixture.todo.id } }, + }); }); test("maps not-found and current-domain conflicts", async () => { @@ -166,6 +228,62 @@ describe("Project Todo routes", () => { error: { code: "PROJECT_TODO_REVISION_CONFLICT", message: "stale" }, }); }); + + test("reads the fixed bounded Todo Plan and returns null when it is absent", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const planDirectory = join(workspaceRoot, ".archcode", "plans"); + await mkdir(planDirectory, { recursive: true }); + const planPath = join(planDirectory, `${todo.id}.md`); + await writeFile(planPath, "# Plan\n\nShip it.\n"); + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + plan: { + path: `.archcode/plans/${todo.id}.md`, + markdown: "# Plan\n\nShip it.\n", + updatedAt: expect.any(Number), + }, + }); + + await rm(planPath); + const missing = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(missing.status).toBe(200); + expect(await missing.json()).toEqual({ plan: null }); + }); + + test("rejects a Todo Plan symlink instead of reading outside the fixed path", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const planDirectory = join(workspaceRoot, ".archcode", "plans"); + const outsidePath = join(workspaceRoot, "outside.md"); + await mkdir(planDirectory, { recursive: true }); + await writeFile(outsidePath, "outside"); + await symlink(outsidePath, join(planDirectory, `${todo.id}.md`)); + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "TODO_PLAN_UNSAFE_PATH" } }, + }); + }); + + test("rejects a Todo Plan larger than the bounded read limit", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const planDirectory = join(workspaceRoot, ".archcode", "plans"); + const planPath = join(planDirectory, `${todo.id}.md`); + await mkdir(planDirectory, { recursive: true }); + await writeFile(planPath, ""); + await truncate(planPath, 1024 * 1024 + 1); + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "TODO_PLAN_TOO_LARGE" } }, + }); + }); }); function createFixture(todo: ProjectTodo) { @@ -177,12 +295,32 @@ function createFixture(todo: ProjectTodo) { }; const service = { listTodos: mock(async () => [todo]), + readTodo: mock(async (todoId: string) => { + if (todoId !== todo.id) throw Object.assign(new Error("missing"), { code: "PROJECT_TODO_NOT_FOUND", todoId }); + return todo; + }), createTodo: mock(async (input: ProjectTodoCreateInput) => ({ ...todo, ...input })), updateTodo: mock(async (_todoId: string, _input: ProjectTodoUpdateInput) => todo), createSession: mock(async (_todoId: string, _input: CreateProjectTodoSessionInput) => ({ todo, sessionId: "11111111-1111-4111-8111-111111111111", })), + runNow: mock(async (_input: ProjectTodoRunNowInput) => ({ + todo, + session: { + sessionId: "11111111-1111-4111-8111-111111111111", + cwd: workspaceRoot, + rootSessionId: "11111111-1111-4111-8111-111111111111", + agentName: "lead" as const, + profile: "principal" as const, + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: todo.title, + source: { kind: "todo" as const, todoId: todo.id, entry: "work" as const }, + createdAt: todo.createdAt, + updatedAt: todo.updatedAt, + }, + })), } satisfies ProjectTodoServiceLike; const runtime = { projectRegistry: { get: mock(async (slug: string) => slug === project.slug ? project : undefined) }, diff --git a/apps/server/src/routes/todos.ts b/apps/server/src/routes/todos.ts index a97275fb..b492d4d0 100644 --- a/apps/server/src/routes/todos.ts +++ b/apps/server/src/routes/todos.ts @@ -1,9 +1,15 @@ import { Hono } from "hono"; +import { lstat, open, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative } from "node:path"; import type { CreateProjectTodoSessionInput, CreateProjectTodoSessionResponse, ProjectTodo, ProjectTodoCreateInput, + ProjectTodoPlan, + ProjectTodoPlanResponse, + ProjectTodoRunNowInput, + ProjectTodoRunNowResponse, ProjectTodoUpdateInput, } from "@archcode/protocol"; import { @@ -13,6 +19,7 @@ import { } from "@archcode/protocol"; import { CreateProjectTodoSessionSchema, + ProjectTodoRunNowSchema, type AgentRuntime, } from "@archcode/agent-core"; import { z } from "zod/v4"; @@ -47,14 +54,17 @@ const ProjectTodoUpdateBodySchema = z.strictObject({ context.addIssue({ code: "custom", path: ["archived"], message: "archived cannot be combined with other Todo fields" }); } }); +const MAX_TODO_PLAN_BYTES = 1024 * 1024; export interface ProjectTodoServiceLike { listTodos(): Promise; + readTodo(todoId: string): Promise; createTodo(input: ProjectTodoCreateInput): Promise; updateTodo(todoId: string, input: ProjectTodoUpdateInput): Promise; createSession( todoId: string, input: CreateProjectTodoSessionInput, ): Promise; + runNow(input: ProjectTodoRunNowInput): Promise; } export function createTodosRoutes(runtime: AgentRuntime): Hono { @@ -85,6 +95,37 @@ export function createTodosRoutes(runtime: AgentRuntime): Hono { }, ); + app.post( + "/:slug/todos/run-now", + zValidator("param", ProjectTodoListParamsSchema), + zValidator("json", ProjectTodoRunNowSchema), + async (c) => { + const project = await resolveProject(runtime, c.req.valid("param").slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + return c.json(await service.runNow(c.req.valid("json")), 201); + } catch (error) { + throw mapTodoError(error); + } + }, + ); + + app.get( + "/:slug/todos/:todoId/plan", + zValidator("param", ProjectTodoParamsSchema), + async (c) => { + const { slug, todoId } = c.req.valid("param"); + const project = await resolveProject(runtime, slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + await service.readTodo(todoId); + return c.json({ plan: await readTodoPlan(project.workspaceRoot, todoId) } satisfies ProjectTodoPlanResponse); + } catch (error) { + throw mapTodoError(error); + } + }, + ); + app.patch( "/:slug/todos/:todoId", zValidator("param", ProjectTodoParamsSchema), @@ -120,6 +161,57 @@ export function createTodosRoutes(runtime: AgentRuntime): Hono { return app; } +export async function readTodoPlan( + workspaceRoot: string, + todoId: string, +): Promise { + const relativePath = join(".archcode", "plans", `${todoId}.md`); + const candidate = join(workspaceRoot, relativePath); + let candidateInfo; + try { + candidateInfo = await lstat(candidate); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return null; + throw error; + } + if (candidateInfo.isSymbolicLink() || !candidateInfo.isFile()) { + throw new ServerError("BAD_REQUEST", "Todo Plan must be a regular file", 400, { scopeCode: "TODO_PLAN_UNSAFE_PATH" }); + } + + const [canonicalWorkspace, canonicalCandidate] = await Promise.all([ + realpath(workspaceRoot), + realpath(candidate), + ]); + const canonicalPlansRoot = join(canonicalWorkspace, ".archcode", "plans"); + const relativeCandidate = relative(canonicalPlansRoot, canonicalCandidate); + if (relativeCandidate !== `${todoId}.md` || relativeCandidate.startsWith("..") || isAbsolute(relativeCandidate)) { + throw new ServerError("BAD_REQUEST", "Todo Plan resolves outside the project plans directory", 400, { scopeCode: "TODO_PLAN_UNSAFE_PATH" }); + } + + const handle = await open(canonicalCandidate, "r"); + try { + const fileInfo = await handle.stat(); + if (!fileInfo.isFile()) { + throw new ServerError("BAD_REQUEST", "Todo Plan must be a regular file", 400, { scopeCode: "TODO_PLAN_UNSAFE_PATH" }); + } + if (fileInfo.size > MAX_TODO_PLAN_BYTES) { + throw new ServerError("BAD_REQUEST", "Todo Plan exceeds the 1 MiB read limit", 413, { scopeCode: "TODO_PLAN_TOO_LARGE" }); + } + const buffer = Buffer.allocUnsafe(MAX_TODO_PLAN_BYTES + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > MAX_TODO_PLAN_BYTES) { + throw new ServerError("BAD_REQUEST", "Todo Plan exceeds the 1 MiB read limit", 413, { scopeCode: "TODO_PLAN_TOO_LARGE" }); + } + return { + path: relativePath, + markdown: buffer.subarray(0, bytesRead).toString("utf8"), + updatedAt: fileInfo.mtimeMs, + }; + } finally { + await handle.close(); + } +} + async function resolveTodos(runtime: AgentRuntime, workspaceRoot: string): Promise { const context = await runtime.contextResolver.resolve(workspaceRoot); return context.todos; @@ -132,6 +224,19 @@ function mapTodoError(error: unknown): Error { if (hasCode(error, "PROJECT_TODO_NOT_FOUND")) { return new ServerError("PROJECT_TODO_NOT_FOUND", error.message, 404, { todoId: error.todoId }); } + if (hasCode(error, "PROJECT_TODO_RUN_NOW_CONFLICT")) { + return new ServerError("BAD_REQUEST", error.message, 409, { + scopeCode: error.code, + clientRequestId: "clientRequestId" in error ? error.clientRequestId : undefined, + }); + } + if (hasCode(error, "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED")) { + return new ServerError("INTERNAL_ERROR", error.message, 500, { + scopeCode: error.code, + todoId: error.todoId, + sessionId: "sessionId" in error ? error.sessionId : undefined, + }); + } if (isProjectTodoConflict(error)) { return new ServerError(error.code, error.message, 409, error); } @@ -142,6 +247,10 @@ function hasCode(error: unknown, code: string): error is Error & { readonly code return error instanceof Error && "code" in error && error.code === code; } +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + type ProjectTodoConflictCode = | "PROJECT_TODO_REVISION_CONFLICT" | "PROJECT_TODO_INVALID_MUTATION" diff --git a/apps/web/package.json b/apps/web/package.json index 07b8c5f2..11b8945d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "bun test --isolate && bun run test:interaction", - "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SidebarToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/features/Sidebar.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" + "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" }, "dependencies": { "@archcode/protocol": "workspace:*", diff --git a/apps/web/src/api/automations.test.ts b/apps/web/src/api/automations.test.ts index a9faf708..833cbc40 100644 --- a/apps/web/src/api/automations.test.ts +++ b/apps/web/src/api/automations.test.ts @@ -37,13 +37,12 @@ describe("Automation API queries", () => { }); }); -test("Automation invalidation refreshes list, dashboard, detail, and history", async () => { +test("Automation invalidation refreshes list, Home, detail, and history", async () => { const calls: unknown[] = []; await invalidateAutomation({ invalidateQueries: async (input) => { calls.push(input.queryKey); } }, "demo", "a1"); expect(calls).toEqual([ queryKeys.projectAutomations("demo"), - queryKeys.dashboardProjection({ kind: "global" }), - queryKeys.dashboardProjection({ kind: "project", projectSlug: "demo" }), + queryKeys.home, queryKeys.automation("demo", "a1"), queryKeys.automationInvocations("demo", "a1"), ]); diff --git a/apps/web/src/api/mutations.test.ts b/apps/web/src/api/mutations.test.ts index bbdbb758..c8b00480 100644 --- a/apps/web/src/api/mutations.test.ts +++ b/apps/web/src/api/mutations.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { createSession, deleteSession, invalidateSessionModelSelectionQuery, patchSessionModelSelection, postMessage, setSessionGoalBudget, stopSessionFamily, uploadSessionAttachment } from "./mutations"; +import { createSession, deleteSession, invalidateProjectCatalog, invalidateSessionModelSelectionQuery, patchSessionModelSelection, postMessage, setSessionGoalBudget, stopSessionFamily, uploadSessionAttachment } from "./mutations"; +import { queryKeys } from "./queries"; const originalFetch = globalThis.fetch; const originalDocument = globalThis.document; @@ -10,6 +11,18 @@ afterEach(() => { globalThis.document = originalDocument; }); +describe("project catalog mutation invalidation", () => { + test("refreshes both the Project Rail and global Home projection", async () => { + const invalidateQueries = mock(async () => undefined); + + await invalidateProjectCatalog({ invalidateQueries } as never); + + expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.projects }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.home }); + }); +}); + function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: init.status ?? 200, diff --git a/apps/web/src/api/mutations.ts b/apps/web/src/api/mutations.ts index 0a60eda2..6fed82ae 100644 --- a/apps/web/src/api/mutations.ts +++ b/apps/web/src/api/mutations.ts @@ -15,6 +15,11 @@ import type { Project, Session, SessionSummary, + ProjectSessionInventoryItem, + ProjectTodoRunNowResponse, + Automation, + AutomationAction, + AutomationTrigger, UpdateAutomationPayload, ProjectTodo, ProjectTodoCreateInput, @@ -23,6 +28,15 @@ import type { } from "./types"; import { createClientUuid } from "../lib/client-uuid"; +export async function invalidateProjectCatalog( + queryClient: Pick, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.projects }), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), + ]); +} + export function useUpdateProjectName() { const queryClient = useQueryClient(); @@ -33,7 +47,7 @@ export function useUpdateProjectName() { body: { name }, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + await invalidateProjectCatalog(queryClient); }, }); } @@ -47,7 +61,7 @@ export function useAddProject() { body: { workspaceRoot: path, ...(name ? { name } : {}) }, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + await invalidateProjectCatalog(queryClient); }, }); } @@ -61,7 +75,7 @@ export function useDeleteProject() { }), onSuccess: async (_data, slug) => { removeProjectControlPlane(slug); - await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + await invalidateProjectCatalog(queryClient); }, }); } @@ -106,9 +120,9 @@ export function useDeleteSession() { return useMutation({ mutationFn: (input: DeleteSessionInput) => deleteSession(input), onSuccess: (_data, variables) => { - queryClient.setQueryData( + queryClient.setQueryData( queryKeys.sessions(variables.slug), - (sessions) => sessions?.filter((session) => session.sessionId !== variables.rootSessionId), + (items) => items?.filter((item) => item.session.sessionId !== variables.rootSessionId), ); for (const deletedSessionId of new Set(variables.sessionIds)) { queryClient.removeQueries({ @@ -126,15 +140,7 @@ export function useDeleteSession() { void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.sessions(variables.slug) }), queryClient.invalidateQueries({ queryKey: queryKeys.projectTodos(variables.slug) }), - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardProjection({ kind: "global" }), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardProjection({ - kind: "project", - projectSlug: variables.slug, - }), - }), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), ]); }, }); @@ -347,7 +353,7 @@ async function invalidateSessionGoalQueries(queryClient: QueryClient, slug: stri await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.session(slug, sessionId) }), queryClient.invalidateQueries({ queryKey: queryKeys.sessions(slug) }), - ...invalidateDashboardProjection(queryClient, slug), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), ]); } @@ -406,7 +412,7 @@ export function invalidateAutomation( ): Promise { return Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.projectAutomations(slug) }), - ...invalidateDashboardProjection(qc, slug), + qc.invalidateQueries({ queryKey: queryKeys.home }), ...(automationId === undefined ? [] : [ qc.invalidateQueries({ queryKey: queryKeys.automation(slug, automationId) }), qc.invalidateQueries({ queryKey: queryKeys.automationInvocations(slug, automationId) }), @@ -478,6 +484,26 @@ export function useDeleteAutomation() { }); } +export interface CreateAutomationInput { + slug: string; + name: string; + trigger: AutomationTrigger; + action: AutomationAction; +} + +export function useCreateAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ slug, ...input }: CreateAutomationInput) => apiFetch<{ automation: Automation }>( + `/api/projects/${encodeURIComponent(slug)}/automations`, + { method: "POST", body: input }, + ), + onSuccess: async (_data, variables) => { + await invalidateAutomation(queryClient, variables.slug); + }, + }); +} + // ─── Project Todo mutations ─── type TodoMutationVariables = { slug: string; todoId: string }; @@ -503,21 +529,11 @@ async function invalidateProjectTodoSession( await Promise.all([ invalidateProjectTodo(queryClient, slug), queryClient.invalidateQueries({ queryKey: queryKeys.sessions(slug) }), - ...invalidateDashboardProjection(queryClient, slug), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), queryClient.invalidateQueries({ queryKey: queryKeys.projectAutomations(slug) }), ]); } -function invalidateDashboardProjection( - qc: { invalidateQueries: (opts: { queryKey: readonly unknown[] }) => Promise }, - slug: string, -): Promise[] { - return [ - qc.invalidateQueries({ queryKey: queryKeys.dashboardProjection({ kind: "global" }) }), - qc.invalidateQueries({ queryKey: queryKeys.dashboardProjection({ kind: "project", projectSlug: slug }) }), - ]; -} - export function useCreateProjectTodo() { const queryClient = useQueryClient(); return useMutation({ @@ -532,6 +548,20 @@ export function useCreateProjectTodo() { }); } +export function useRunProjectTodoNow() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ slug, clientRequestId, title, body }: { slug: string; clientRequestId: string; title: string; body?: string }) => + apiFetch(`${todoUrl(slug)}/run-now`, { + method: "POST", + body: { clientRequestId, title, ...(body === undefined ? {} : { body }) }, + }), + onSettled: async (_data, _error, variables) => { + await invalidateProjectTodoSession(queryClient, variables.slug); + }, + }); +} + export function useUpdateProjectTodo() { const queryClient = useQueryClient(); return useMutation({ diff --git a/apps/web/src/api/queries.test.ts b/apps/web/src/api/queries.test.ts index 684ab462..dc5254f8 100644 --- a/apps/web/src/api/queries.test.ts +++ b/apps/web/src/api/queries.test.ts @@ -1,13 +1,11 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import type { AgentDescriptor, - DashboardProjection, SessionGoal, SessionSummary, } from "@archcode/protocol"; import { agentsQueryOptions, - dashboardProjectionQueryOptions, diffQueryOptions, queryKeys, sessionsQueryOptions, @@ -98,55 +96,6 @@ describe("web Session Goal query contracts", () => { ).toEqual(sessions); }); - test("fetches the shared Dashboard projection in global scope", async () => { - globalThis.document = { cookie: "" } as Document; - const projection: DashboardProjection = { - scope: { kind: "global" }, - sessions: [], - automations: [], - errors: [], - }; - globalThis.fetch = mock(async (input) => { - expect(String(input)).toBe("/api/dashboard"); - return jsonResponse(projection); - }) as unknown as typeof fetch; - const options = dashboardProjectionQueryOptions({ kind: "global" }); - expect([...options.queryKey]).toEqual(["dashboard", "global"]); - expect( - await ( - options as unknown as QueryOptionWithFn - ).queryFn(), - ).toEqual(projection); - }); - - test("fetches the same Dashboard projection contract in project scope", async () => { - globalThis.document = { cookie: "" } as Document; - const projection: DashboardProjection = { - scope: { kind: "project", projectSlug: "demo space" }, - sessions: [], - automations: [], - errors: [], - }; - globalThis.fetch = mock(async (input) => { - expect(String(input)).toBe("/api/projects/demo%20space/dashboard"); - return jsonResponse(projection); - }) as unknown as typeof fetch; - const options = dashboardProjectionQueryOptions({ - kind: "project", - projectSlug: "demo space", - }); - expect([...options.queryKey]).toEqual([ - "dashboard", - "project", - "demo space", - ]); - expect( - await ( - options as unknown as QueryOptionWithFn - ).queryFn(), - ).toEqual(projection); - }); - test("keeps Diff scoped to a Session", async () => { globalThis.document = { cookie: "" } as Document; globalThis.fetch = mock(async (input) => { diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index 98249d92..5433f0c5 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiFetch } from "./client"; -import type { AgentDescriptor, DashboardProjection, DashboardScope, ModelRuntimeCatalog } from "@archcode/protocol"; +import type { AgentDescriptor, HomeResponse, ModelRuntimeCatalog, WorkSearchResponse } from "@archcode/protocol"; import type { DiffFile, DirectoryListResponse, @@ -10,6 +10,9 @@ import type { Project, Session, SessionSummary, + ProjectSessionInventoryItem, + ProjectAutomationInventoryItem, + ProjectTodoPlan, SessionTreeResponse, ProjectTodo, } from "./types"; @@ -22,9 +25,8 @@ export const queryKeys = { modelRuntime: ["config", "model-runtime"] as const, update: ["update"] as const, projects: ["projects"] as const, - dashboardProjection: (scope: DashboardScope) => scope.kind === "global" - ? ["dashboard", "global"] as const - : ["dashboard", "project", scope.projectSlug] as const, + home: ["home"] as const, + workSearch: (query: string) => ["search", query] as const, sessions: (slug: string) => ["projects", slug, "sessions"] as const, session: (slug: string, sessionId: string) => ["projects", slug, "sessions", sessionId] as const, tree: (slug: string, rootSessionId: string) => ["projects", slug, "sessions", rootSessionId, "tree"] as const, @@ -66,11 +68,24 @@ export function projectsQueryOptions() { }); } +export function homeQueryOptions() { + return queryOptions({ queryKey: queryKeys.home, queryFn: () => apiFetch("/api/home") }); +} + +export function workSearchQueryOptions(query: string) { + const normalized = query.trim(); + return queryOptions({ + queryKey: queryKeys.workSearch(normalized), + queryFn: () => apiFetch(`/api/search?q=${encodeURIComponent(normalized)}`), + enabled: normalized.length > 0 && normalized.length <= 200, + }); +} + export function sessionsQueryOptions(slug: string) { return queryOptions({ queryKey: queryKeys.sessions(slug), queryFn: async () => { - const response = await apiFetch<{ sessions: SessionSummary[] }>( + const response = await apiFetch<{ sessions: ProjectSessionInventoryItem[] }>( `/api/projects/${encodeURIComponent(slug)}/sessions`, ); return response.sessions; @@ -107,15 +122,6 @@ export function focusedSessionQueryOptions(slug: string, focusSessionId: string }); } -export function dashboardProjectionQueryOptions(scope: DashboardScope) { - return queryOptions({ - queryKey: queryKeys.dashboardProjection(scope), - queryFn: () => apiFetch(scope.kind === "global" - ? "/api/dashboard" - : `/api/projects/${encodeURIComponent(scope.projectSlug)}/dashboard`), - }); -} - export function diffQueryOptions(slug: string, sessionId?: string) { const scopedSessionId = sessionId?.trim() || undefined; return queryOptions({ @@ -138,6 +144,14 @@ export function useProjects() { return useQuery(projectsQueryOptions()); } +export function useHome() { + return useQuery(homeQueryOptions()); +} + +export function useWorkSearch(query: string) { + return useQuery(workSearchQueryOptions(query)); +} + export function useAgents() { return useQuery(agentsQueryOptions()); } @@ -147,6 +161,10 @@ export function useModelRuntime() { } export function useSessions(slug: string) { + return useQuery({ ...sessionsQueryOptions(slug), select: (items) => items.map((item) => item.session) }); +} + +export function useSessionInventory(slug: string) { return useQuery(sessionsQueryOptions(slug)); } @@ -175,11 +193,6 @@ export function useSessionTree(slug: string, rootSessionId: string) { return useQuery(sessionTreeQueryOptions(slug, rootSessionId)); } -/** Fetches the raw server read projection; UI composition remains local and transient. */ -export function useDashboardReadProjection(scope: DashboardScope) { - return useQuery(dashboardProjectionQueryOptions(scope)); -} - export function useDiff( slug: string, sessionId?: string, @@ -223,7 +236,7 @@ export function automationsQueryOptions(slug: string) { return queryOptions({ queryKey: queryKeys.projectAutomations(slug), queryFn: async () => { - const response = await apiFetch<{ automations: Automation[] }>( + const response = await apiFetch<{ automations: ProjectAutomationInventoryItem[] }>( `/api/projects/${encodeURIComponent(slug)}/automations`, ); return response.automations; @@ -261,6 +274,10 @@ export function automationInvocationsQueryOptions(slug: string, automationId: st // ─── Automation hooks ─── export function useAutomations(slug: string) { + return useQuery({ ...automationsQueryOptions(slug), select: (items) => items.map((item) => item.automation) }); +} + +export function useAutomationInventory(slug: string) { return useQuery(automationsQueryOptions(slug)); } @@ -288,3 +305,17 @@ export function projectTodosQueryOptions(slug: string) { export function useProjectTodos(slug: string) { return useQuery(projectTodosQueryOptions(slug)); } + +export function projectTodoPlanQueryOptions(slug: string, todoId: string) { + return queryOptions({ + queryKey: ["projects", slug, "todos", todoId, "plan"] as const, + queryFn: () => apiFetch<{ plan: ProjectTodoPlan | null }>( + `/api/projects/${encodeURIComponent(slug)}/todos/${encodeURIComponent(todoId)}/plan`, + ).then((response) => response.plan), + enabled: slug.length > 0 && todoId.length > 0, + }); +} + +export function useProjectTodoPlan(slug: string, todoId: string) { + return useQuery(projectTodoPlanQueryOptions(slug, todoId)); +} diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index a1ce569f..2d9fb572 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -5,6 +5,10 @@ export type { DirectoryListResponse, DirectorySearchResponse, SessionSummary, + RootSessionSummary, + ProjectSessionInventoryItem, + ProjectAutomationInventoryItem, + ProjectTodoRunNowResponse, SessionTreeResponse, SessionTreeNode, SessionTreeDiagnostic, @@ -39,13 +43,6 @@ export type { ProjectTodoCreateInput, ProjectTodoUpdateInput, CreateProjectTodoSessionInput, - DashboardScope, - DashboardProjection, - DashboardRootSession, - DashboardExecution, - DashboardAutomation, - DashboardAutomationInvocation, - DashboardProjectError, } from "@archcode/protocol"; import type { @@ -57,10 +54,16 @@ import type { SessionSummary, } from "@archcode/protocol"; +export interface ProjectTodoPlan { + path: string; + markdown: string; + updatedAt: number; +} + /** Complete persisted Session file returned by the Session detail endpoint. */ export type Session = ProtocolSession & Pick; -/** Visible Session-owned Goal projection, returned by Session and dashboard APIs. */ +/** Visible Session-owned Goal projection returned by Session and Home APIs. */ export type SessionGoalView = SessionGoal; export type SessionWithGoal = Session & { goal?: SessionGoalView }; diff --git a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx index 3448e5f7..14b09533 100644 --- a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx +++ b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx @@ -241,6 +241,7 @@ async function render( messages: SessionMessage[], execution: SessionExecutionRecord, steps: SessionStep[] = [], + focusClientRequestId?: string, ): Promise { await act(async () => { createWebSessionStore("session", "project") @@ -265,6 +266,7 @@ async function render( sessionId="session" sessionIdentity={{ agentName: "lead", profile: "principal" }} agents={[]} + focusClientRequestId={focusClientRequestId} /> , @@ -419,6 +421,20 @@ describe("ExecutionWorkstream", () => { ).toContain("Two"); }); + test("focuses and highlights the canonical message matched by an Invocation client request id", async () => { + await render( + [{ ...(message("invocation-message", "user", "Automated instruction", 10) as Extract), clientRequestId: "invocation-1" }], + completed(), + [], + "invocation-1", + ); + + const target = container.querySelector('[data-client-request-id="invocation-1"]'); + expect(target).not.toBeNull(); + expect(target?.className).toContain("ring-brand"); + expect(document.activeElement).toBe(target); + }); + test("renders ordered independent Work Segments while final output stays with the last segment", async () => { await render( [ diff --git a/apps/web/src/components/composite/ExecutionWorkstream.tsx b/apps/web/src/components/composite/ExecutionWorkstream.tsx index 3d03da16..c6875288 100644 --- a/apps/web/src/components/composite/ExecutionWorkstream.tsx +++ b/apps/web/src/components/composite/ExecutionWorkstream.tsx @@ -713,6 +713,7 @@ interface ExecutionTurnProps { onButtonRef: (segmentId: string, button: HTMLButtonElement | null) => void; onArticleRef: (segmentId: string, article: HTMLElement | null) => void; onInspectModelAudit?: (messageId: string) => void; + focusClientRequestId?: string | null; } const ExecutionTurn = memo(function ExecutionTurn({ @@ -724,6 +725,7 @@ const ExecutionTurn = memo(function ExecutionTurn({ onInspectModelAudit, onButtonRef, onArticleRef, + focusClientRequestId, }: ExecutionTurnProps) { executionTurnRenderObserverForTest?.(execution.id); @@ -736,9 +738,11 @@ const ExecutionTurn = memo(function ExecutionTurn({
onArticleRef(segment.id, section)} - className="flex min-w-0 scroll-mt-4 flex-col gap-3" + className={`flex min-w-0 scroll-mt-4 flex-col gap-3 ${segment.inputMessage?.clientRequestId === focusClientRequestId ? "rounded-md ring-2 ring-brand ring-offset-2 ring-offset-bg-base" : ""}`} + data-client-request-id={segment.inputMessage?.clientRequestId} data-execution-navigation-target={segment.id} data-work-segment={segment.id} + tabIndex={segment.inputMessage?.clientRequestId === focusClientRequestId ? -1 : undefined} > {segment.inputMessage && ( void; + focusClientRequestId?: string | null; } export function ExecutionWorkstream({ @@ -837,6 +842,7 @@ export function ExecutionWorkstream({ sessionIdentity, agents, onInspectModelAudit, + focusClientRequestId, }: ExecutionWorkstreamProps) { const messages = useSessionStore(sessionId, (state) => state.messages, slug); const executions = useSessionStore( @@ -897,6 +903,12 @@ export function ExecutionWorkstream({ () => projection.executions.flatMap((execution) => execution.segments), [projection.executions], ); + const focusedSegmentId = useMemo( + () => focusClientRequestId === null || focusClientRequestId === undefined + ? undefined + : segments.find((segment) => segment.inputMessage?.clientRequestId === focusClientRequestId)?.id, + [focusClientRequestId, segments], + ); const uiSnapshotRef = useRef( getWorkstreamUiSnapshot(slug, routeScopeId, sessionId), @@ -1628,6 +1640,18 @@ export function ExecutionWorkstream({ [clearPendingInputDirection, clearTouchMomentum, setFollowLatest], ); + const appliedClientRequestFocusRef = useRef(null); + useLayoutEffect(() => { + if (focusClientRequestId === null || focusClientRequestId === undefined || focusedSegmentId === undefined) return; + const focusKey = `${focusClientRequestId}\u0000${focusedSegmentId}`; + if (appliedClientRequestFocusRef.current === focusKey) return; + const target = articleByExecutionIdRef.current.get(focusedSegmentId); + if (!target) return; + appliedClientRequestFocusRef.current = focusKey; + jumpToExecution(focusedSegmentId, "auto"); + target.focus({ preventScroll: true }); + }, [focusClientRequestId, focusedSegmentId, jumpToExecution]); + const isEmpty = projection.items.length === 0 && projection.diagnostics.length === 0; @@ -1690,6 +1714,7 @@ export function ExecutionWorkstream({ onButtonRef={registerWorkButton} onArticleRef={registerExecutionArticle} onInspectModelAudit={onInspectModelAudit} + focusClientRequestId={focusClientRequestId} /> ); } diff --git a/apps/web/src/components/features/AddProjectModal.test.tsx b/apps/web/src/components/features/AddProjectModal.test.tsx index 065f07c8..5bc63c30 100644 --- a/apps/web/src/components/features/AddProjectModal.test.tsx +++ b/apps/web/src/components/features/AddProjectModal.test.tsx @@ -240,7 +240,7 @@ describe("AddProjectModal", () => { expect("name" in (addProjectMutate.mock.calls[0]?.[0] as Record)).toBe(false); }); - test("opens a successfully registered project's Dashboard", () => { + test("opens a successfully registered project's Todos", () => { const tree = renderWithState(["", "", "/workspace/archcode", -1]); const submit = findAll( tree, @@ -256,7 +256,7 @@ describe("AddProjectModal", () => { addedAt: "2026-01-01T00:00:00.000Z", }); - expect(navigate).toHaveBeenCalledWith("/projects/archcode"); + expect(navigate).toHaveBeenCalledWith("/projects/archcode/todos"); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/components/features/AddProjectModal.tsx b/apps/web/src/components/features/AddProjectModal.tsx index 7bd24a2e..451eba2f 100644 --- a/apps/web/src/components/features/AddProjectModal.tsx +++ b/apps/web/src/components/features/AddProjectModal.tsx @@ -143,7 +143,7 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { { path: selectedPath }, { onSuccess: (project) => { - navigate(`/projects/${project.slug}`); + navigate(`/projects/${project.slug}/todos`); onClose(); }, }, diff --git a/apps/web/src/components/features/ChatHeader.test.tsx b/apps/web/src/components/features/ChatHeader.test.tsx index 6b18ef7c..50349f95 100644 --- a/apps/web/src/components/features/ChatHeader.test.tsx +++ b/apps/web/src/components/features/ChatHeader.test.tsx @@ -99,7 +99,6 @@ describe("ChatHeader", () => { ); expect(status?.textContent).toContain("Needs you"); expect(status?.getAttribute("data-product-status")).toBe("needs_you"); - expect(container.querySelector('header > button[aria-label="Collapse project sidebar"]')).not.toBeNull(); expect(container.querySelector('header button[aria-label="Expand context inspector"]')).not.toBeNull(); }); }); diff --git a/apps/web/src/components/features/ChatHeader.tsx b/apps/web/src/components/features/ChatHeader.tsx index 1fc46f7e..2d2ac0d1 100644 --- a/apps/web/src/components/features/ChatHeader.tsx +++ b/apps/web/src/components/features/ChatHeader.tsx @@ -2,7 +2,6 @@ import { Link } from "react-router-dom"; import { useSessionStore } from "../../store/session-store"; import { TodoProgressButton } from "./TodoProgressButton"; import { InspectorToggleButton } from "./InspectorToggleButton"; -import { SidebarToggleButton } from "./SidebarToggleButton"; import { executionVisualKind, presentExecutionStatus, @@ -66,7 +65,6 @@ export function ChatHeader({ return (
-

diff --git a/apps/web/src/components/features/ChatInput.tsx b/apps/web/src/components/features/ChatInput.tsx index dd66e30a..97494200 100644 --- a/apps/web/src/components/features/ChatInput.tsx +++ b/apps/web/src/components/features/ChatInput.tsx @@ -59,6 +59,7 @@ export interface ChatInputProps { activity: SessionFamilyActivity | undefined; hitlReady: boolean; hasPendingHitl: boolean; + focusOnReady?: boolean; } function composerStatus( @@ -85,6 +86,7 @@ export function ChatInput({ activity, hitlReady, hasPendingHitl, + focusOnReady = false, }: ChatInputProps) { const [value, setValue] = useState(""); const [showSlashMenu, setShowSlashMenu] = useState(false); @@ -94,6 +96,7 @@ export function ChatInput({ const [attachments, setAttachments] = useState([]); const [attachmentNotice, setAttachmentNotice] = useState(); const textareaRef = useRef(null); + const focusOnReadyAppliedRef = useRef(false); const slashMenuRef = useRef(null); const fileInputRef = useRef(null); const attachmentsRef = useRef([]); @@ -154,6 +157,12 @@ export function ChatInput({ if (!hasPendingHitl) setHitlComposerExpanded(false); }, [hasPendingHitl]); + useEffect(() => { + if (!focusOnReady || !canCompose || focusOnReadyAppliedRef.current) return; + focusOnReadyAppliedRef.current = true; + textareaRef.current?.focus(); + }, [canCompose, focusOnReady]); + useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( diff --git a/apps/web/src/components/features/CloseProjectDialog.test.tsx b/apps/web/src/components/features/CloseProjectDialog.test.tsx index 3724e488..76294e5b 100644 --- a/apps/web/src/components/features/CloseProjectDialog.test.tsx +++ b/apps/web/src/components/features/CloseProjectDialog.test.tsx @@ -137,7 +137,7 @@ describe("CloseProjectDialog", () => { expect(copy).toContain("The workspace folder will"); expect(copy).toContain("not"); expect(copy).toContain("be deleted"); - expect(copy).toContain("removes the project from the sidebar"); + expect(copy).toContain("removes the project from ArchCode"); }); test("destructive Close Project button deletes by slug and fires callbacks on success", () => { diff --git a/apps/web/src/components/features/CloseProjectDialog.tsx b/apps/web/src/components/features/CloseProjectDialog.tsx index c8d9051f..72fbb025 100644 --- a/apps/web/src/components/features/CloseProjectDialog.tsx +++ b/apps/web/src/components/features/CloseProjectDialog.tsx @@ -71,7 +71,7 @@ export function CloseProjectDialog({

The workspace folder will not be deleted. This only - removes the project from the sidebar. + removes the project from ArchCode.

diff --git a/apps/web/src/components/features/ComposerQueueList.interaction.tsx b/apps/web/src/components/features/ComposerQueueList.interaction.tsx index e6c7dcff..e4116d51 100644 --- a/apps/web/src/components/features/ComposerQueueList.interaction.tsx +++ b/apps/web/src/components/features/ComposerQueueList.interaction.tsx @@ -83,6 +83,7 @@ beforeEach(async () => { Object.defineProperties(dom.window.HTMLElement.prototype, { attachEvent: { configurable: true, value: () => {} }, detachEvent: { configurable: true, value: () => {} }, + scrollIntoView: { configurable: true, value: () => {} }, }); for (const [name, value] of Object.entries({ window: dom.window, @@ -206,7 +207,7 @@ describe("ComposerQueueList", () => { client.setQueryData(queryKeys.modelRuntime, modelRuntime); await act(async () => { - root.render(); + root.render(); await Promise.resolve(); }); @@ -231,6 +232,9 @@ describe("ComposerQueueList", () => { expect(localModel?.className).toContain("max-[560px]:max-w-16"); const queuedRow = container.querySelector('[data-testid="composer-queue-queued-row"]'); + expect(queuedRow?.getAttribute("data-client-request-id")).toBe("queued-client"); + expect(queuedRow?.className).toContain("ring-brand"); + expect(document.activeElement).toBe(queuedRow); expect(queuedRow?.textContent).toContain("Steer"); expect(queuedRow?.textContent).toContain("Edit"); expect(queuedRow?.textContent).toContain("Delete"); diff --git a/apps/web/src/components/features/ComposerQueueList.tsx b/apps/web/src/components/features/ComposerQueueList.tsx index bc4be7c1..9b8672a9 100644 --- a/apps/web/src/components/features/ComposerQueueList.tsx +++ b/apps/web/src/components/features/ComposerQueueList.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LoaderCircle, TriangleAlert } from "lucide-react"; import type { ExecutionModelBindingSummary, @@ -30,7 +30,7 @@ type QueueEntry = | { kind: "durable"; message: PendingSessionMessage; time: number; order: number } | { kind: "local"; message: LocalSendingMessage; time: number; order: number }; -export function ComposerQueueList({ slug, sessionId }: { slug: string; sessionId: string }) { +export function ComposerQueueList({ slug, sessionId, focusClientRequestId }: { slug: string; sessionId: string; focusClientRequestId?: string | null }) { const pendingMessages = useSessionStore(sessionId, (state) => state.pendingMessages, slug); const localSendingMessages = useSessionStore(sessionId, (state) => state.localSendingMessages, slug); const activeModelBinding = useSessionStore(sessionId, (state) => state.activeModelBinding, slug); @@ -58,6 +58,7 @@ export function ComposerQueueList({ slug, sessionId }: { slug: string; sessionId sessionId={sessionId} slug={slug} steerTargetExecutionId={steerTargetExecutionId} + focused={entry.message.clientRequestId === focusClientRequestId} /> ) : ( @@ -90,6 +91,7 @@ function DurableQueueRow({ activeModelBinding, modelRuntime, nextModelSelection, + focused, }: { message: PendingSessionMessage; slug: string; @@ -98,6 +100,7 @@ function DurableQueueRow({ activeModelBinding?: ExecutionModelBindingSummary; modelRuntime?: ModelRuntimeCatalog; nextModelSelection?: SessionNextModelSelection; + focused: boolean; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(message.content); @@ -118,6 +121,13 @@ function DurableQueueRow({ const mutationError = queueMutationError(editMessage.error, deleteMessage.error, steerMessage.error); const busy = editMessage.isPending || deleteMessage.isPending || steerMessage.isPending; const nextDraft = draft.trim(); + const rowRef = useRef(null); + + useEffect(() => { + if (!focused || !rowRef.current) return; + rowRef.current.scrollIntoView({ block: "nearest" }); + rowRef.current.focus({ preventScroll: true }); + }, [focused, message.id]); const openEditor = () => { editMessage.reset(); @@ -127,9 +137,12 @@ function DurableQueueRow({ return (
{message.state === "steering" ? ( diff --git a/apps/web/src/components/features/DeleteResourceDialogs.test.tsx b/apps/web/src/components/features/DeleteResourceDialogs.test.tsx index 39837d51..6d6bf38e 100644 --- a/apps/web/src/components/features/DeleteResourceDialogs.test.tsx +++ b/apps/web/src/components/features/DeleteResourceDialogs.test.tsx @@ -66,7 +66,7 @@ const childSession = session("child-session", "Explore implementation", rootSess const automation: Automation = { id: "automation-1", projectSlug: "demo", - createdFromSessionId: "source-session", + origin: { kind: "session", sessionId: "source-session" }, name: "Continue review", trigger: { kind: "interval", everyMs: 60_000 }, action: { diff --git a/apps/web/src/components/features/EditAutomationDialog.interaction.tsx b/apps/web/src/components/features/EditAutomationDialog.interaction.tsx index 22a89d42..98d2357e 100644 --- a/apps/web/src/components/features/EditAutomationDialog.interaction.tsx +++ b/apps/web/src/components/features/EditAutomationDialog.interaction.tsx @@ -22,7 +22,7 @@ const automationTimezone = "Asia/Shanghai"; const automation: Automation = { id: "automation-edit-limits", projectSlug: "archcode", - createdFromSessionId: "session-source", + origin: { kind: "session", sessionId: "session-source" }, name: "Daily review", trigger: { kind: "cron", expression: "0 9 * * *", timezone: automationTimezone }, action: { kind: "start_session", message: "Review current work.", location: "project" }, @@ -145,4 +145,36 @@ describe("EditAutomationDialog limits", () => { expect(saveButton().disabled).toBe(false); } }); + + test("protects a dirty draft and states the fixed Lead principal contract", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + let closeCount = 0; + await act(async () => { + root.render( + + { closeCount += 1; }} slug="archcode" automation={automation} /> + , + ); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("root Lead Session with the principal profile"); + change(field("automation-name"), "Changed draft"); + const cancel = [...document.querySelectorAll("button")] + .find((candidate) => candidate.textContent === "Cancel"); + if (cancel === undefined) throw new Error("Missing Cancel button"); + + let confirmCount = 0; + dom.window.confirm = () => { + confirmCount += 1; + return false; + }; + act(() => cancel.click()); + expect(confirmCount).toBe(1); + expect(closeCount).toBe(0); + + dom.window.confirm = () => true; + act(() => cancel.click()); + expect(closeCount).toBe(1); + }); }); diff --git a/apps/web/src/components/features/EditAutomationDialog.tsx b/apps/web/src/components/features/EditAutomationDialog.tsx index 86825e50..260a9e07 100644 --- a/apps/web/src/components/features/EditAutomationDialog.tsx +++ b/apps/web/src/components/features/EditAutomationDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AUTOMATION_MESSAGE_MAX_LENGTH, AUTOMATION_NAME_MAX_LENGTH, @@ -17,7 +17,7 @@ import { type LucideIcon, } from "lucide-react"; -import { useUpdateAutomation } from "../../api/mutations"; +import { useCreateAutomation, useUpdateAutomation } from "../../api/mutations"; import type { Automation, AutomationAction, @@ -60,7 +60,7 @@ interface EditAutomationDialogProps { open: boolean; onClose: () => void; slug: string; - automation: Automation; + automation?: Automation; } export function EditAutomationDialog({ @@ -70,6 +70,27 @@ export function EditAutomationDialog({ automation, }: EditAutomationDialogProps) { const update = useUpdateAutomation(); + const create = useCreateAutomation(); + const initial = useMemo(() => { + const interval = automation?.trigger.kind === "interval" + ? intervalFromMilliseconds(automation.trigger.everyMs) + : { value: 1, unit: "minutes" as const }; + return { + name: automation?.name ?? "", + triggerKind: automation?.trigger.kind ?? "interval" as const, + onceAt: automation?.trigger.kind === "once" ? automation.trigger.at.slice(0, 16) : "", + intervalValue: interval.value, + intervalUnit: interval.unit, + cron: automation?.trigger.kind === "cron" ? automation.trigger.expression : "*/15 * * * *", + timezone: automation?.trigger.kind === "cron" + ? automation.trigger.timezone + : Intl.DateTimeFormat().resolvedOptions().timeZone, + actionKind: automation?.action.kind ?? "start_session" as const, + message: automation?.action.message ?? "", + sessionId: automation?.action.kind === "send_message" ? automation.action.sessionId : "", + location: automation?.action.kind === "start_session" ? automation.action.location : "project" as const, + }; + }, [automation]); const [name, setName] = useState(""); const [triggerKind, setTriggerKind] = useState("interval"); const [onceAt, setOnceAt] = useState(""); @@ -84,27 +105,37 @@ export function EditAutomationDialog({ useEffect(() => { if (!open) return; - const interval = automation.trigger.kind === "interval" - ? intervalFromMilliseconds(automation.trigger.everyMs) - : { value: 1, unit: "minutes" as const }; - - setName(automation.name); - setTriggerKind(automation.trigger.kind); - setOnceAt(automation.trigger.kind === "once" ? automation.trigger.at.slice(0, 16) : ""); - setIntervalValue(interval.value); - setIntervalUnit(interval.unit); - setCron(automation.trigger.kind === "cron" ? automation.trigger.expression : "*/15 * * * *"); - setTimezone(automation.trigger.kind === "cron" - ? automation.trigger.timezone - : Intl.DateTimeFormat().resolvedOptions().timeZone); - setActionKind(automation.action.kind); - setMessage(automation.action.message); - setSessionId(automation.action.kind === "send_message" ? automation.action.sessionId : ""); - setLocation(automation.action.kind === "start_session" ? automation.action.location : "project"); - }, [open, automation.id]); + setName(initial.name); + setTriggerKind(initial.triggerKind); + setOnceAt(initial.onceAt); + setIntervalValue(initial.intervalValue); + setIntervalUnit(initial.intervalUnit); + setCron(initial.cron); + setTimezone(initial.timezone); + setActionKind(initial.actionKind); + setMessage(initial.message); + setSessionId(initial.sessionId); + setLocation(initial.location); + }, [initial, open]); - const pending = update.isPending; - const error = update.error; + const pending = update.isPending || create.isPending; + const error = update.error ?? create.error; + const dirty = name !== initial.name + || triggerKind !== initial.triggerKind + || onceAt !== initial.onceAt + || intervalValue !== initial.intervalValue + || intervalUnit !== initial.intervalUnit + || cron !== initial.cron + || timezone !== initial.timezone + || actionKind !== initial.actionKind + || message !== initial.message + || sessionId !== initial.sessionId + || location !== initial.location; + const requestClose = () => { + if (pending) return; + if (dirty && !window.confirm("Discard unsaved Automation changes?")) return; + onClose(); + }; const everyMs = intervalToMilliseconds(intervalValue, intervalUnit); const valid = name.trim().length > 0 && name.trim().length <= AUTOMATION_NAME_MAX_LENGTH @@ -130,7 +161,8 @@ export function EditAutomationDialog({ ? { kind: "start_session", message: message.trim(), location } : { kind: "send_message", message: message.trim(), sessionId: sessionId.trim() }; const payload: Required = { name: name.trim(), trigger, action }; - update.mutate({ slug, automationId: automation.id, ...payload }, { onSuccess: onClose }); + if (automation) update.mutate({ slug, automationId: automation.id, ...payload }, { onSuccess: onClose }); + else create.mutate({ slug, ...payload }, { onSuccess: onClose }); }; const errorMessage = error @@ -140,7 +172,7 @@ export function EditAutomationDialog({ : null; return ( - { if (!next && !pending) onClose(); }}> + { if (!next) requestClose(); }}>
@@ -149,7 +181,7 @@ export function EditAutomationDialog({
- Edit Automation + {automation ? "Edit Automation" : "New Automation"} Schedule an ordinary Session message. The Session keeps its existing tools and permissions. @@ -157,10 +189,10 @@ export function EditAutomationDialog({
@@ -348,6 +380,7 @@ export function EditAutomationDialog({ onChange={() => setLocation("worktree")} />
+ Every run starts a root Lead Session with the principal profile. ) : (
@@ -389,18 +422,18 @@ export function EditAutomationDialog({
diff --git a/apps/web/src/components/features/HitlBell.tsx b/apps/web/src/components/features/HitlBell.tsx index ffddebcd..a96dd5fb 100644 --- a/apps/web/src/components/features/HitlBell.tsx +++ b/apps/web/src/components/features/HitlBell.tsx @@ -43,8 +43,8 @@ export function HitlBell({ aria-label="Open requests needing attention" aria-expanded={open} aria-controls="hitl-bell-panel" - className={`relative flex h-8 w-8 items-center justify-center rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand ${variant === "rail" - ? "text-rail-muted hover:bg-white/7 hover:text-rail-ink" + className={`relative flex h-8 w-8 items-center justify-center rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11 ${variant === "rail" + ? "text-rail-muted hover:bg-rail-ink/8 hover:text-rail-ink" : "text-text-tertiary hover:bg-bg-hover hover:text-text-primary" }`} onClick={() => { @@ -66,7 +66,7 @@ export function HitlBell({ : "absolute bottom-10 left-10 z-50 w-[min(360px,calc(100vw-1rem))] rounded-lg border border-border-default bg-bg-overlay p-3 shadow-md" } > -
+
); -} \ No newline at end of file +} diff --git a/apps/web/src/components/features/ProjectBar.test.tsx b/apps/web/src/components/features/ProjectBar.test.tsx index af337a9a..43c11d9e 100644 --- a/apps/web/src/components/features/ProjectBar.test.tsx +++ b/apps/web/src/components/features/ProjectBar.test.tsx @@ -75,6 +75,7 @@ const useState = mock((initial: T): [T, (value: T | ((previous: T) => T)) => ]); const useCallback = mock( unknown>(callback: T) => callback); const useNavigate = mock(() => navigate); +const useLocation = mock(() => ({ pathname: "/projects/demo-project/todos" })); const useParams = mock(() => ({ slug: "demo-project" })); const useProjects = mock(() => ({ data: [project] })); const toggleTheme = mock(() => {}); @@ -99,10 +100,12 @@ mock.module("react/jsx-dev-runtime", () => ({ mock.module("react-router-dom", () => ({ useNavigate, + useLocation, useParams, })); mock.module("lucide-react", () => ({ + Search: "Search", Moon: "Moon", Plus: "Plus", Settings: "Settings", @@ -175,7 +178,7 @@ function projectNode(tree: unknown) { describe("ProjectBar", () => { beforeEach(() => { attentionVisibleHitl = []; - for (const fn of [navigate, onAddProject, onSettings, setState, useState, useCallback, useNavigate, useParams, useProjects, toggleTheme]) { + for (const fn of [navigate, onAddProject, onSettings, setState, useState, useCallback, useNavigate, useLocation, useParams, useProjects, toggleTheme]) { fn.mockClear(); } }); @@ -210,7 +213,7 @@ describe("ProjectBar", () => { expect(textContent(node)).toContain("de"); node.props.onClick({ ctrlKey: false, metaKey: false }); - expect(navigate).toHaveBeenCalledWith("/projects/demo-project"); + expect(navigate).toHaveBeenCalledWith("/projects/demo-project/todos"); }); test("add project affordance is a native button", () => { diff --git a/apps/web/src/components/features/ProjectBar.tsx b/apps/web/src/components/features/ProjectBar.tsx index 779abf20..20b49cc5 100644 --- a/apps/web/src/components/features/ProjectBar.tsx +++ b/apps/web/src/components/features/ProjectBar.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { Moon, Plus, Settings, Sun } from "lucide-react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { Moon, Plus, Search, Settings, Sun } from "lucide-react"; import { useProjects } from "../../api/queries"; import type { Theme } from "../../hooks/use-theme"; import { useAttentionVisibleScopedHitl } from "../../store/hitl-store"; @@ -13,6 +13,8 @@ import type { Project } from "../../api/types"; interface ProjectBarProps { onAddProject?: () => void; onSettings?: () => void; + onSearch?: () => void; + searchTriggerRef?: React.RefObject; showBell?: boolean; theme: Theme; toggleTheme: () => void; @@ -22,8 +24,9 @@ function getInitials(slug: string): string { return slug.slice(0, 2).toLowerCase(); } -export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, toggleTheme }: ProjectBarProps) { +export function ProjectBar({ onAddProject, onSettings, onSearch, searchTriggerRef, showBell = true, theme, toggleTheme }: ProjectBarProps) { const navigate = useNavigate(); + const location = useLocation(); const { slug: activeSlug } = useParams<{ slug: string }>(); const { data: projects } = useProjects(); const attentionVisibleHitl = useAttentionVisibleScopedHitl(); @@ -34,7 +37,7 @@ export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, t const handleProjectClick = (slug: string, e?: React.MouseEvent) => { // Ctrl-click / Cmd-click should not navigate — context menu handles it if (e && (e.ctrlKey || e.metaKey)) return; - navigate(`/projects/${slug}`); + navigate(`/projects/${slug}/todos`); }; const handleAddProject = () => { @@ -50,7 +53,7 @@ export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, t if (project.slug === activeSlug) { const remaining = projects?.filter((p) => p.slug !== project.slug); if (remaining && remaining.length > 0) { - navigate(`/projects/${remaining[0].slug}`); + navigate(`/projects/${remaining[0].slug}/todos`); } else { navigate("/"); } @@ -67,8 +70,9 @@ export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, t > {showBell && } + )} + />} +
+ + {editingProject && setEditingProject(null)} />} + {closingProject && ( + setClosingProject(null)} + onClosed={handleProjectClosed} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/features/SessionComposerDock.tsx b/apps/web/src/components/features/SessionComposerDock.tsx index e6220b51..e1cbd02b 100644 --- a/apps/web/src/components/features/SessionComposerDock.tsx +++ b/apps/web/src/components/features/SessionComposerDock.tsx @@ -12,10 +12,14 @@ export function SessionComposerDock({ slug, sessionId, focusHitlId, + focusComposer = false, + focusClientRequestId, }: { slug: string; sessionId: string; focusHitlId?: string | null; + focusComposer?: boolean; + focusClientRequestId?: string | null; }) { const activity = useSessionFamilyActivity(slug, sessionId); const goal = useSessionStore(sessionId, (state) => state.goal, slug); @@ -90,7 +94,7 @@ export function SessionComposerDock({ )} - +
diff --git a/apps/web/src/components/features/Sidebar.interaction.tsx b/apps/web/src/components/features/Sidebar.interaction.tsx deleted file mode 100644 index 7c439b06..00000000 --- a/apps/web/src/components/features/Sidebar.interaction.tsx +++ /dev/null @@ -1,451 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import type { - Automation, - Project, - ProjectTodo, - SessionSummaryWithGoal, -} from "../../api/types"; -import type { SessionFamilyActivity } from "@archcode/protocol"; -import type { ScopedHitlView } from "../../store/hitl-store"; - -const navigationCalls: string[] = []; -const navigate = mock((path: string) => { - navigationCalls.push(path); -}); -const createSession = { isPending: false, mutate: mock(() => {}) }; -const postMessage = { mutate: mock(() => {}) }; - -let route = { - pathname: "/projects/demo/sessions/recent", - params: { slug: "demo", sessionId: "recent", automationId: "" }, -}; -let sessions: SessionSummaryWithGoal[] = []; -let automations: Automation[] = []; -let projectTodos: ProjectTodo[] = []; -let runtimeInitialized = true; -let runtimeFamilies: Record< - string, - { activity: SessionFamilyActivity } -> = {}; -let attentionVisibleHitl: ScopedHitlView[] = []; - -const Icon = (props: Record) => ; -const ListTodoIcon = (props: Record) => ( - -); - -mock.module("react-router-dom", () => ({ - Link: ({ to, children, ...props }: { to: string; children?: ReactNode }) => ( - - {children} - - ), - useLocation: () => ({ pathname: route.pathname }), - useNavigate: () => navigate, - useParams: () => route.params, -})); - -mock.module("lucide-react", () => ({ - ChevronRight: Icon, - Focus: Icon, - LayoutDashboard: Icon, - ListTodo: ListTodoIcon, - MoreHorizontal: Icon, - PanelLeftClose: Icon, - Plus: Icon, - Trash2: Icon, - TriangleAlert: Icon, -})); - -mock.module("../../api/mutations", () => ({ - useCreateSession: () => createSession, - useDeleteSession: () => ({ mutate: mock(() => {}), isPending: false, error: null }), - usePostMessage: () => postMessage, -})); - -const project: Project = { - slug: "demo", - name: "Demo project", - workspaceRoot: "/workspace/demo", - addedAt: "2026-07-25T00:00:00.000Z", -}; - -mock.module("../../api/queries", () => ({ - useProjects: () => ({ data: [project] }), - useSessions: () => ({ data: sessions }), - useSessionTree: () => ({ data: undefined, isLoading: false, error: null }), - useAutomations: () => ({ data: automations }), - useProjectTodos: () => ({ data: projectTodos }), -})); - -mock.module("../../store/session-runtime-store", () => ({ - runtimeFamilyKey: (slug: string, sessionId: string) => `${slug}:${sessionId}`, - useSessionRuntimeInitialized: () => runtimeInitialized, - useSessionRuntimeFamilies: () => runtimeFamilies, -})); - -mock.module("../../store/hitl-store", () => ({ - selectSessionFamilyHitl: ( - entries: ScopedHitlView[], - slug: string, - rootSessionId: string, - ) => - entries.filter( - (entry) => - entry.projectSlug === slug && entry.rootSessionId === rootSessionId, - ), - useAttentionVisibleScopedHitl: () => attentionVisibleHitl, -})); - -mock.module("../primitives/StatusGlyph", () => ({ - StatusGlyph: ({ kind, label }: { kind: string; label?: string }) => ( - - ), -})); - -mock.module("./GoalStatusMark", () => ({ - GoalStatusMark: ({ label }: { label?: string }) => ( - - ), -})); - -mock.module("./ProjectActionMenu", () => ({ - ProjectActionDropdown: ({ trigger }: { trigger: ReactNode }) => ( - <>{trigger} - ), -})); - -mock.module("./EditProjectDialog", () => ({ EditProjectDialog: () => null })); -mock.module("./CloseProjectDialog", () => ({ CloseProjectDialog: () => null })); - -const { Sidebar } = await import("./Sidebar"); - -function session( - sessionId: string, - title: string, - updatedAt: number, - goal?: SessionSummaryWithGoal["goal"], -): SessionSummaryWithGoal { - return { - sessionId, - title, - updatedAt, - createdAt: updatedAt - 1, - cwd: "/workspace/demo", - rootSessionId: sessionId, - agentName: "lead", - profile: "principal", - activeSkillNames: [], - modelSelection: { model: "test:model" }, - goal, - } as unknown as SessionSummaryWithGoal; -} - -function attention( - rootSessionId: string, - source: "tool_permission" | "ask_user", -): ScopedHitlView { - return { - projectSlug: "demo", - ownerSessionId: rootSessionId, - rootSessionId, - view: { - hitlId: `${rootSessionId}-${source}`, - owner: { type: "session", id: rootSessionId }, - source: - source === "tool_permission" - ? { type: "tool_permission", toolCallId: "tool-1", toolName: "bash" } - : { type: "ask_user", toolCallId: "tool-1" }, - status: "pending", - displayPayload: { title: "Needs a response", redacted: true }, - allowedActions: - source === "tool_permission" ? ["approve", "deny"] : ["answer"], - createdAt: "2026-07-25T00:00:00.000Z", - updatedAt: "2026-07-25T00:00:00.000Z", - }, - }; -} - -function automation(id: string, name: string): Automation { - return { - id, - name, - status: "active", - trigger: { kind: "once", at: "2026-07-26T00:00:00.000Z" }, - action: { - kind: "send_message", - sessionId: "recent", - content: "Review status", - }, - createdAt: "2026-07-25T00:00:00.000Z", - updatedAt: "2026-07-25T00:00:00.000Z", - } as unknown as Automation; -} - -function projectTodo( - id: string, - status: ProjectTodo["status"], - archivedAt?: number, -): ProjectTodo { - return { - id, - title: id, - body: "", - status, - revision: 1, - createdAt: 1, - updatedAt: 1, - ...(archivedAt === undefined ? {} : { archivedAt }), - }; -} - -let dom: JSDOM; -let root: Root; -let container: HTMLElement; -const originals = new Map(); - -beforeEach(() => { - dom = new JSDOM( - '
', - { url: "http://localhost" }, - ); - for (const [name, value] of Object.entries({ - window: dom.window, - document: dom.window.document, - navigator: dom.window.navigator, - HTMLElement: dom.window.HTMLElement, - Node: dom.window.Node, - MouseEvent: dom.window.MouseEvent, - IS_REACT_ACT_ENVIRONMENT: true, - })) { - originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); - Object.defineProperty(globalThis, name, { configurable: true, value }); - } - navigationCalls.length = 0; - route = { - pathname: "/projects/demo/sessions/recent", - params: { slug: "demo", sessionId: "recent", automationId: "" }, - }; - runtimeInitialized = true; - runtimeFamilies = { - "demo:running": { activity: "running" }, - "demo:recent": { activity: "idle" }, - }; - sessions = [ - session("permission", "Review destructive command", 1_753_000_000_000), - session("question", "Choose the rollout plan", 1_753_000_001_000), - session("mixed", "Resolve pending requests", 1_753_000_001_500), - session("running", "Rebuild execution surface", 1_753_000_002_000), - session("recent", "Tighten workbench hierarchy", 1_753_000_003_000, { - instanceId: "goal-1", - settlementReceipts: [], - generation: 1, - objective: "Ship the workbench", - status: "active", - usage: { - tokens: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - reasoningTokens: 0, - cachedInputTokens: 0, - }, - executionTimeMs: 0, - executionCount: 0, - }, - createdAt: 1, - activatedAt: 1, - updatedAt: 1, - }), - ]; - attentionVisibleHitl = [ - attention("permission", "tool_permission"), - attention("question", "ask_user"), - attention("mixed", "tool_permission"), - attention("mixed", "ask_user"), - ]; - automations = [automation("auto-1", "Nightly review")]; - projectTodos = [ - projectTodo("idea", "idea"), - projectTodo("ready", "ready"), - projectTodo("done", "done"), - projectTodo("rejected", "rejected"), - projectTodo("archived", "idea", 2), - ]; - container = document.getElementById("root")!; - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - dom.window.close(); - for (const [name, descriptor] of originals) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else Reflect.deleteProperty(globalThis, name); - } - originals.clear(); -}); - -async function renderSidebar(): Promise { - await act(async () => root.render()); -} - -describe("Sidebar Session list", () => { - test("gives Todos a distinct icon and counts only open active work", async () => { - await renderSidebar(); - - const todoLink = container.querySelector( - 'a[href="/projects/demo/todos"]', - ) as HTMLAnchorElement; - expect(todoLink.querySelector('[data-icon="list-todo"]')).not.toBeNull(); - expect( - todoLink.querySelector('[data-testid="sidebar-todo-count"]')?.textContent, - ).toBe("2"); - expect(todoLink.getAttribute("aria-label")).toBe("Todos, 2 open"); - }); - - test("groups by attention, live work, then recency using one-line accessible Session rows", async () => { - await renderSidebar(); - - const groupOrder = [ - ...container.querySelectorAll("[data-testid^=sidebar-session-group-]"), - ].map((group) => group.getAttribute("data-testid")); - expect(groupOrder).toEqual([ - "sidebar-session-group-needs-you", - "sidebar-session-group-running", - "sidebar-session-group-recent", - ]); - - const permission = container.querySelector( - '[data-testid="sidebar-session-permission"]', - ) as HTMLButtonElement; - const question = container.querySelector( - '[data-testid="sidebar-session-question"]', - ) as HTMLButtonElement; - const mixed = container.querySelector( - '[data-testid="sidebar-session-mixed"]', - ) as HTMLButtonElement; - const running = container.querySelector( - '[data-testid="sidebar-session-running"]', - ) as HTMLButtonElement; - const recent = container.querySelector( - '[data-testid="sidebar-session-recent"]', - ) as HTMLButtonElement; - expect( - permission.querySelector('[data-visual-kind="needs_you"]'), - ).not.toBeNull(); - expect( - question.querySelector( - '[data-testid="sidebar-session-attention-question"]', - )?.textContent, - ).toContain("Question"); - expect( - permission.querySelector( - '[data-testid="sidebar-session-attention-permission"]', - )?.textContent, - ).toContain("Permission"); - expect( - mixed.querySelector('[data-testid="sidebar-session-attention-mixed"]') - ?.textContent, - ).toContain("2 requests"); - expect(mixed.textContent).not.toContain("Permission"); - expect(mixed.textContent).not.toContain("Question"); - expect(mixed.getAttribute("aria-label")).toContain("2 requests waiting"); - expect( - running.querySelector('[data-visual-kind="running"]'), - ).not.toBeNull(); - expect(recent.querySelector('[data-visual-kind="idle"]')).not.toBeNull(); - expect( - recent.querySelector('[data-testid="sidebar-session-goal-recent"]'), - ).not.toBeNull(); - expect(recent.getAttribute("aria-current")).toBe("page"); - expect(permission.getAttribute("aria-label")).toContain("Needs attention"); - expect(permission.getAttribute("aria-label")).toContain( - "Permission waiting", - ); - expect(running.textContent).not.toContain("running ·"); - const relativeTime = permission.querySelector("time") as HTMLTimeElement; - expect(relativeTime).not.toBeNull(); - expect(relativeTime.dateTime).toBe( - new Date(sessions[0]!.updatedAt).toISOString(), - ); - expect(relativeTime.title).not.toBe(""); - expect(relativeTime.getAttribute("aria-label")).toContain( - relativeTime.textContent, - ); - expect(permission.getAttribute("aria-label")).toContain( - relativeTime.textContent, - ); - expect(permission.getAttribute("title")).toBeNull(); - expect(permission.className).toContain("h-9"); - expect(permission.className).toContain("[@media(pointer:coarse)]:min-h-11"); - expect( - container.querySelector( - 'button[aria-label="Actions for Tighten workbench hierarchy"]', - ), - ).not.toBeNull(); - - await act(async () => permission.click()); - expect(navigationCalls).toEqual(["/projects/demo/sessions/permission"]); - - route = { ...route, params: { ...route.params, sessionId: "permission" } }; - await renderSidebar(); - expect(permission.getAttribute("aria-current")).toBe("page"); - expect(recent.getAttribute("aria-current")).toBeNull(); - }); - - test("keeps waiting and resuming families in the live group without HITL", async () => { - runtimeFamilies = { - "demo:waiting": { activity: "waiting_for_human" }, - "demo:resuming": { activity: "resuming" }, - }; - sessions = [ - session("waiting", "Waiting on child", 2), - session("resuming", "Resuming work", 1), - session("idle", "Recent work", 0), - ]; - attentionVisibleHitl = []; - - await renderSidebar(); - - const liveGroup = container.querySelector('[data-testid="sidebar-session-group-running"]') as HTMLElement; - const waiting = container.querySelector('[data-testid="sidebar-session-waiting"]') as HTMLButtonElement; - const resuming = container.querySelector('[data-testid="sidebar-session-resuming"]') as HTMLButtonElement; - expect(liveGroup).not.toBeNull(); - expect(waiting.getAttribute("aria-label")).toContain("Waiting"); - expect(waiting.querySelector('[data-visual-kind="pending"]')).not.toBeNull(); - expect(resuming.getAttribute("aria-label")).toContain("Resuming"); - expect(resuming.querySelector('[data-visual-kind="running"]')).not.toBeNull(); - }); - - test("keeps Automation navigation and creation surface available from its tab", async () => { - await renderSidebar(); - - const automationTab = [...container.querySelectorAll('[role="tab"]')].find( - (element) => element.textContent === "Automations", - ) as HTMLButtonElement; - await act(async () => automationTab.click()); - - const panel = container.querySelector( - "#sidebar-panel-automations", - ) as HTMLElement; - expect(panel.hidden).toBe(false); - const row = container.querySelector( - '[data-testid="sidebar-automation-auto-1"]', - ) as HTMLButtonElement; - expect(row.textContent).toContain("Nightly review"); - expect(row.textContent).not.toContain("auto-1"); - expect(row.className).toContain("h-8"); - expect(row.className).toContain("[@media(pointer:coarse)]:min-h-11"); - expect(row.getAttribute("aria-label")).toContain("auto-1"); - await act(async () => row.click()); - expect(navigationCalls).toEqual(["/projects/demo/automations/auto-1"]); - }); -}); diff --git a/apps/web/src/components/features/Sidebar.tsx b/apps/web/src/components/features/Sidebar.tsx deleted file mode 100644 index 76cac891..00000000 --- a/apps/web/src/components/features/Sidebar.tsx +++ /dev/null @@ -1,747 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; -import { - ChevronRight, - LayoutDashboard, - ListTodo, - MoreHorizontal, - Plus, - Trash2, - type LucideIcon, -} from "lucide-react"; -import { useCreateSession, usePostMessage } from "../../api/mutations"; -import { useAutomations, useProjects, useProjectTodos, useSessions } from "../../api/queries"; -import type { - Automation, - Project, - SessionSummary, - SessionSummaryWithGoal, -} from "../../api/types"; -import type { SessionFamilyActivity } from "@archcode/protocol"; -import { ProjectActionDropdown } from "./ProjectActionMenu"; -import { EditProjectDialog } from "./EditProjectDialog"; -import { CloseProjectDialog } from "./CloseProjectDialog"; -import { DeleteSessionDialog } from "./DeleteSessionDialog"; -import { - runtimeFamilyKey, - useSessionRuntimeFamilies, - useSessionRuntimeInitialized, -} from "../../store/session-runtime-store"; -import { - selectSessionFamilyHitl, - type ScopedHitlView, - useAttentionVisibleScopedHitl, -} from "../../store/hitl-store"; -import { StatusGlyph } from "../primitives/StatusGlyph"; -import { GoalStatusMark } from "./GoalStatusMark"; -import { presentSessionGoalStatus } from "../../lib/session-goal-presentation"; -import { automationVisualKind } from "../../lib/automation-status-presentation"; -import { sessionFamilyActivityLabel, sessionFamilyVisual } from "../../lib/session-family-presentation"; -import { - RelativeTimeValue, - useRelativeTimePresentation, -} from "../primitives/TemporalText"; -import { - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRoot, - DropdownMenuTrigger, -} from "../ui/DropdownMenu"; - -// Helpers - -/** Robust lowercase string conversion that never throws on nullish/unknown values. */ -function toSearchable(value: unknown): string { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - try { - return String(value); - } catch { - return ""; - } -} - -// Tab model - -type SidebarTab = "sessions" | "automations"; - -const TABS: Array<{ id: SidebarTab; label: string }> = [ - { id: "sessions", label: "Sessions" }, - { id: "automations", label: "Automations" }, -]; - -export function deriveSidebarTabFromPath(pathname: string): SidebarTab | null { - if (pathname.includes("/sessions/")) return "sessions"; - if (pathname.includes("/automations")) return "automations"; - return null; -} - -// Session list projection - -type SessionListGroup = "needs-you" | "running" | "recent"; - -interface SessionAttention { - readonly count: number; - readonly label: "Permission" | "Question" | "Requests"; -} - -interface SidebarSessionRow { - readonly session: SessionSummaryWithGoal; - readonly activity: SessionFamilyActivity | undefined; - readonly attention?: SessionAttention; -} - -function sessionAttention(entries: readonly ScopedHitlView[]): SessionAttention | undefined { - const primary = entries[0]; - if (!primary) return undefined; - const sourceTypes = new Set(entries.map((entry) => entry.view.source.type)); - return { - count: entries.length, - label: sourceTypes.size > 1 - ? "Requests" - : primary.view.source.type === "tool_permission" - ? "Permission" - : "Question", - }; -} - -function attentionLabel(attention: SessionAttention): string { - return attention.label === "Requests" - ? `${attention.count} requests` - : attention.label; -} - -function sessionListGroup( - activity: SessionFamilyActivity | undefined, - attention: SessionAttention | undefined, -): SessionListGroup { - if (attention) return "needs-you"; - if (activity !== undefined && activity !== "idle") return "running"; - return "recent"; -} - -function sessionStateLabel( - activity: SessionFamilyActivity | undefined, - attention: SessionAttention | undefined, -): string { - if (attention) return "Needs attention"; - return sessionFamilyActivityLabel(activity); -} - -// Status glyphs - -function SessionStatusGlyph({ - activity, - attention, -}: { - activity: SessionFamilyActivity | undefined; - attention?: SessionAttention; -}) { - if (attention) return ; - const visual = sessionFamilyVisual(activity); - return ; -} - -function AutomationStatusGlyph({ status }: { status: Automation["status"] }) { - return ; -} - -// List items - -function SessionItem({ - session, - activity, - attention, - isActive, - onClick, - onDelete, -}: { - session: SessionSummaryWithGoal; - activity: SessionFamilyActivity | undefined; - attention?: SessionAttention; - isActive: boolean; - onClick: () => void; - onDelete: () => void; -}) { - const updatedAt = session.updatedAt; - const goalLabel = session.goal ? presentSessionGoalStatus(session.goal.status).label : undefined; - const stateLabel = sessionStateLabel(activity, attention); - const visibleAttentionLabel = attention ? attentionLabel(attention) : undefined; - const relativeUpdatedAt = useRelativeTimePresentation(updatedAt); - const accessibleName = [ - session.title || "Untitled", - stateLabel, - attention - ? attention.label === "Requests" - ? `${attention.count} requests waiting` - : `${attention.label} waiting${attention.count > 1 ? `, ${attention.count} requests` : ""}` - : undefined, - goalLabel ? `Goal ${goalLabel}` : undefined, - relativeUpdatedAt.full, - ].filter((part): part is string => part !== undefined).join(" · "); - - return ( -
- {isActive && ( - - ); -} - -function AutomationItem({ - automation, - isActive, - onClick, -}: { - automation: Automation; - isActive: boolean; - onClick: () => void; -}) { - const scheduleLabel = automation.trigger.kind === "once" ? "once" : automation.trigger.kind === "interval" ? `interval ${automation.trigger.everyMs}ms` : `cron ${automation.trigger.expression}`; - const actionLabel = automation.action.kind.replaceAll("_", " "); - const accessibleName = `${automation.name} · ${automation.status} · ${scheduleLabel} · ${actionLabel} · ${automation.id}`; - - return ( - - ); -} - -// Shared sub-components - -const SEARCH_INPUT_CLASS = - "w-full rounded-md border border-border-control bg-bg-elevated px-3 py-2 text-[13px] text-text-primary placeholder:text-text-muted transition-colors duration-[var(--motion-hover)] focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand-subtle"; - -function CreateButton({ - onClick, - title, - label, - disabled, -}: { - onClick: () => void; - title: string; - label: string; - disabled?: boolean; -}) { - return ( - - ); -} - -function SubGroupHeader({ title, count }: { title: string; count?: number }) { - return ( -
- {title} - {count !== undefined && ( - - {count} - - )} -
- ); -} - -function EmptyRow({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function DashboardLinkButton({ - to, - label, - isActive, - icon: Icon = LayoutDashboard, - count, -}: { - to: string; - label: string; - isActive: boolean; - icon?: LucideIcon; - count?: number; -}) { - return ( - - {isActive &&
diff --git a/apps/web/src/routes/project-todo-presentation.test.ts b/apps/web/src/routes/project-todo-presentation.test.ts index 20330eb1..e3e98d7f 100644 --- a/apps/web/src/routes/project-todo-presentation.test.ts +++ b/apps/web/src/routes/project-todo-presentation.test.ts @@ -3,8 +3,6 @@ import { PROJECT_TODO_LANE_PRESENTATIONS, demoteEmbeddedMarkdownHeadings, presentProjectTodoCard, - projectTodoContentPreview, - projectTodoContentRemainder, } from "./project-todo-presentation"; describe("Project Todo presentation", () => { @@ -17,13 +15,6 @@ describe("Project Todo presentation", () => { expect(Object.keys(PROJECT_TODO_LANE_PRESENTATIONS)).toEqual(["idea", "ready", "in_progress", "done"]); }); - test("projects one canonical Markdown document without repeating its display label", () => { - const content = "\n# Add semantic search\n\n## Problem\nDevelopers lose context.\n\n- Search files\n- Keep stable links"; - expect(projectTodoContentRemainder(content)).toBe("## Problem\nDevelopers lose context.\n\n- Search files\n- Keep stable links"); - expect(projectTodoContentPreview(content)).toBe("Problem Developers lose context. Search files Keep stable links"); - expect(projectTodoContentRemainder("One-line capture")).toBe(""); - }); - test("keeps embedded Markdown headings below the detail route heading", () => { expect(demoteEmbeddedMarkdownHeadings("# Plan\n\n## Step\n\n```md\n# example\n```")) .toBe("## Plan\n\n### Step\n\n```md\n# example\n```"); diff --git a/apps/web/src/routes/project-todo-presentation.ts b/apps/web/src/routes/project-todo-presentation.ts index 9e5346c1..3c4362e5 100644 --- a/apps/web/src/routes/project-todo-presentation.ts +++ b/apps/web/src/routes/project-todo-presentation.ts @@ -68,30 +68,6 @@ export const PROJECT_TODO_LANE_PRESENTATIONS: Readonly line.trim().length > 0); - return firstContentLine < 0 ? "" : lines.slice(firstContentLine + 1).join("\n").trim(); -} - -/** Compact plain-text projection for Board cards; canonical Markdown is unchanged. */ -export function projectTodoContentPreview(content: string): string { - return projectTodoContentRemainder(content) - .split(/\r?\n/u) - .map((line) => line.trim() - .replace(/^(?:#{1,6}|>|[-+*]|\d+[.)])\s+/u, "") - .replace(/^\[[ xX]\]\s*/u, "") - .replace(/^```.*$/u, "")) - .filter((line) => line.length > 0) - .join(" ") - .replace(/\s+/gu, " ") - .trim(); -} - /** Keep embedded Todo documents below the route-level h1 without touching code fences. */ export function demoteEmbeddedMarkdownHeadings(markdown: string): string { let fence: { readonly kind: "`" | "~"; readonly length: number } | undefined; diff --git a/apps/web/src/routes/project-todos.test.tsx b/apps/web/src/routes/project-todos.test.tsx index faf1b52d..c7030e4f 100644 --- a/apps/web/src/routes/project-todos.test.tsx +++ b/apps/web/src/routes/project-todos.test.tsx @@ -118,7 +118,7 @@ describe("Project Todos board", () => { expect(moveTodoInBoard(order, "c", "idea", 0).idea).toEqual(["c", "a", "b"]); }); - test("announces the Todo display label, target lane, position, completion, and cancellation", () => { + test("announces the Todo content excerpt, target lane, position, completion, and cancellation", () => { const order = { idea: ["idea"], ready: ["ready"], in_progress: ["progress"], done: ["done"] }; const announcements = createDragAnnouncements(order, new Map(todos.map((todo) => [todo.id, todo]))); const active = { id: "ready" }; @@ -151,7 +151,10 @@ describe("Project Todos board", () => { expect(handle.className).toContain("min-h-11"); expect(handle.className).toContain("w-11"); expect(handle.className).toContain("cursor-grab"); - expect(document.querySelector('[data-testid="todo-open-ready"]')?.className).toContain("cursor-pointer"); + const open = document.querySelector('[data-testid="todo-open-ready"]'); + expect(open?.className).toContain("cursor-pointer"); + expect(open?.querySelector("span")?.className).toContain("line-clamp-2"); + expect(open?.querySelector("span")?.className).not.toContain("block"); const viewButtons = document.querySelector('[aria-label="Todo views"]')?.querySelectorAll("button") ?? []; expect(viewButtons).toHaveLength(3); for (const button of viewButtons) expect(button.className).toContain("[@media(pointer:coarse)]:h-11"); @@ -188,7 +191,7 @@ describe("Project Todos board", () => { const state = document.querySelector('[data-testid="todo-operational-progress"]'); expect(state?.textContent).toBe("Working· Running"); - expect(document.querySelector('[data-testid="todo-open-progress"]')?.textContent).toContain("In Progress"); + expect(document.querySelector('[data-testid="todo-open-progress"]')?.textContent).toBe("ProgressWorking· Running"); expect(document.querySelector('[data-testid="todo-operational-ready"]')).toBeNull(); }); diff --git a/apps/web/src/routes/project-todos.tsx b/apps/web/src/routes/project-todos.tsx index 460140a6..54a803f0 100644 --- a/apps/web/src/routes/project-todos.tsx +++ b/apps/web/src/routes/project-todos.tsx @@ -23,7 +23,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { projectTodoDisplayLabel } from "@archcode/protocol"; +import { projectTodoContentExcerpt } from "@archcode/protocol"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { GripVertical, Plus, Search } from "lucide-react"; import { ApiError } from "../api/client"; @@ -37,9 +37,7 @@ import { useAttentionVisibleScopedHitl, useHitlProjectInitialized } from "../sto import { runtimeFamilyKey, useSessionRuntimeFamilies, useSessionRuntimeInitialized } from "../store/session-runtime-store"; import { deriveProjectTodoOperationalState, - projectTodoContentPreview, PROJECT_TODO_LANE_PRESENTATIONS, - presentProjectTodoCard, type ProjectTodoAttentionLabel, type ProjectTodoLane, type ProjectTodoOperationalState, @@ -398,23 +396,20 @@ function TodoLane({ lane, order, todoById, operationalStateByTodoId, onSelect }: function SortableTodoCard({ todo, operationalState, onSelect }: { todo: ProjectTodo; operationalState?: ProjectTodoOperationalState; onSelect: () => void }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: todo.id, data: { type: "todo", todo } }); - const presentation = presentProjectTodoCard({ status: todo.status, ...(todo.archivedAt === undefined ? {} : { archivedAt: todo.archivedAt }) }); - const { Icon } = presentation; - const label = projectTodoDisplayLabel(todo.content, todo.id); - const preview = projectTodoContentPreview(todo.content); + const excerpt = projectTodoContentExcerpt(todo.content); return
-
+
; } function DragPreview({ todo }: { todo?: ProjectTodo }) { if (!todo) return null; - return

{projectTodoDisplayLabel(todo.content, todo.id)}

; + return

{projectTodoContentExcerpt(todo.content)}

; } function TodoFlatList({ view, todos, onSelect, filtered }: { view: Exclude; todos: ProjectTodo[]; onSelect: (id: string) => void; filtered: boolean }) { const title = view === "rejected" ? "Rejected Todos" : "Archived Todos"; - return

{title}

{todos.length === 0 ?

{todoFlatListEmptyMessage(view, filtered)}

:
{todos.map((todo) => )}
}
; + return

{title}

{todos.length === 0 ?

{todoFlatListEmptyMessage(view, filtered)}

:
{todos.map((todo) => )}
}
; } function ViewButton({ children, active, onClick }: { children: React.ReactNode; active: boolean; onClick: () => void }) { return ; } @@ -425,7 +420,7 @@ function messageFor(cause: unknown): string { return cause instanceof Error ? ca export function createDragAnnouncements(order: BoardOrder, todoById: ReadonlyMap) { const titleFor = (id: string | number) => { const todo = todoById.get(String(id)); - return todo === undefined ? "Todo" : projectTodoDisplayLabel(todo.content, todo.id); + return todo === undefined ? "Todo" : projectTodoContentExcerpt(todo.content); }; const describeTarget = (target: { lane: ProjectTodoLane; index: number } | undefined) => { if (!target) return undefined; diff --git a/apps/web/src/routes/session.tsx b/apps/web/src/routes/session.tsx index f46a38b2..d3b50354 100644 --- a/apps/web/src/routes/session.tsx +++ b/apps/web/src/routes/session.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { ArrowLeft, LoaderCircle } from "lucide-react"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { projectTodoDisplayLabel, rootSessionSourceTodoId, type RootSessionSource } from "@archcode/protocol"; +import { projectTodoContentExcerpt, rootSessionSourceTodoId, type RootSessionSource } from "@archcode/protocol"; import { ExecutionWorkstream, retainExecutionWorkstreamUiState, @@ -420,7 +420,7 @@ export function SessionRoute() { slug, todoLabel: linkedProjectTodo === undefined ? undefined - : projectTodoDisplayLabel(linkedProjectTodo.content, linkedProjectTodo.id), + : projectTodoContentExcerpt(linkedProjectTodo.content), automationTitle: sourceAutomation.data?.name, })} onToggleInspector={toggleInspectorSurface} diff --git a/design-system/MASTER.md b/design-system/MASTER.md index 47411108..7619e4be 100644 --- a/design-system/MASTER.md +++ b/design-system/MASTER.md @@ -215,7 +215,7 @@ Type scale: | Commentary | 13–14px | 400–500 | Process explanation inside Work | | User message | 15px | 400–500 | User intent | | Final response | 15px | 400–600 | Agent outcome and supporting detail | -| Session / Todos title | 20px | 600 | Active work or page identity | +| Project / Session title | 20px | 600 | Active work or page identity | | Global Home title | 26px | 700 | Cross-project operational identity | Rules: @@ -425,6 +425,10 @@ pulse, and terminal cursor may loop. - Global Home and archived/rejected items are rows separated by rules. - Todo cards are one card level only; never nest a card inside another card. +- A Todo has one canonical Markdown `content` value and no title or summary. + Inventory surfaces show only a mechanically normalized, bounded prefix of + that content; Todo detail renders the complete content without removing its + first line. - Selection uses a 2px indigo inset rule plus border change. - Running or attention rows may use a semantic field and 3px inset rule. diff --git a/design-system/pages/automations.md b/design-system/pages/automations.md index cb088716..76386adc 100644 --- a/design-system/pages/automations.md +++ b/design-system/pages/automations.md @@ -40,7 +40,7 @@ attention, and recover the exact Session associated with every run. - Selection uses the shared indigo selected treatment. Needs-attention state keeps explicit text in addition to amber color. - `Filter Automations` matches stable ID, name, instruction, schedule, linked - Todo display label or canonical content, and visible run state. Show a + Todo canonical content, and visible run state. Show a helpful no-results state in place of the list without hiding the filter or New Automation action. diff --git a/design-system/pages/sessions.md b/design-system/pages/sessions.md index 6c4f9d2a..fe55a9ca 100644 --- a/design-system/pages/sessions.md +++ b/design-system/pages/sessions.md @@ -37,7 +37,7 @@ attention, and start work directly without manufacturing a Todo first. ## Search and Filter - `Filter Sessions` covers Session title, stable ID, source type, linked Todo - display label, and Automation name. + content, and Automation name. - Treat search and source as one left-aligned filter cluster with an 8px gap; keep `New Session` independently anchored to the far right. Never distribute the three controls as equal islands across the command row. diff --git a/design-system/pages/todos.md b/design-system/pages/todos.md index d4ce630e..58934b6d 100644 --- a/design-system/pages/todos.md +++ b/design-system/pages/todos.md @@ -62,12 +62,13 @@ Lane rules: lanes remain content-sized with a 160px minimum. - Lane headers use a status orbit, title, short explanation, and count. - Cards use one border, 6px radius, and no elevation. -- Card order is lifecycle state → derived display label → optional remaining - content preview → optional operational line. Linked Sessions, Automations, - and lifecycle actions live on the detail route. -- The display label is derived deterministically from the first meaningful - Markdown line, is never persisted, and is not independently editable. -- Lifecycle state uses its matching status icon plus text; color is secondary. +- A Todo has no title. Each card shows only the first 80 characters of its + canonical Markdown after mechanically removing line-leading Markdown markers + and collapsing whitespace. Clamp this content excerpt to two lines; do not add + a second preview, artifact metadata, or linked-work counts. +- The lane header owns the visible lifecycle label for Board cards. Preserve the + lifecycle state in data, drag announcements, and accessible context rather + than repeating `Idea`, `Ready`, `In Progress`, or `Done` inside every card. - Only In Progress cards may show the compact derived operational line. It is not another Todo lifecycle state and is never persisted. Derive it from the linked Work Session, Todo-origin Automation run, unresolved HITL, Goal, @@ -90,7 +91,7 @@ Lane rules: ## Rejected Surface - Use a flat list at a maximum width of 980px. -- Every row preserves the derived display label and rejection reason. +- Every row preserves the compact content excerpt and rejection reason. - Primary recovery is `Restore to Idea`. - Use amber for the rejected/reconsideration signal, never destructive red. - Do not mix Rejected items back into the active Board. @@ -98,7 +99,7 @@ Lane rules: ## Archived Surface - Use the same flat-list structure as Rejected for visual continuity. -- Show the derived display label and a quiet `Archived` state label. +- Show the compact content excerpt and a quiet `Archived` state label. - Primary recovery is `Restore`. - Archived work remains recoverable but visually quiet. - Do not replace the list with a hidden archive menu. @@ -121,7 +122,7 @@ Lane rules: The route preserves: -- derived display label and lifecycle state; +- lifecycle state and stable Todo ID; - canonical Todo Markdown and one editing control; - linked Discussions, work Sessions, and Automations; - lifecycle-appropriate primary and secondary actions. @@ -132,13 +133,13 @@ and `Lifecycle`. Stack these regions on narrower screens. Do not introduce tabs, collapsible groups, or a second Todo summary model. Keep `Edit` with the canonical content. Editing uses one Markdown textarea and -one Save/Cancel pair. The derived display label represents the first meaningful -Markdown line in the route header; render the remaining Markdown as the -`Brief / PRD` body so the same line is not duplicated. Editing always exposes -the complete canonical content, including that first line. A one-line capture -shows a quiet empty-detail message rather than inventing a second summary. -Demote rendered Brief and Plan headings beneath the route heading; fenced code -content is never rewritten. Detail actions remain grouped by intent: +one Save/Cancel pair. Do not render a content-derived Todo title in the route +header; keep only lifecycle state and the stable ID there. Render the complete +canonical Markdown, including its first line, in `Brief / PRD`. A one-line +capture therefore remains visible as the complete document rather than +producing an empty-detail state. Demote rendered Brief and Plan headings beneath +the route heading; fenced code content is never rewritten. Detail actions remain +grouped by intent: - `Discuss & Plan` contains Continue Discussion when available and New Discussion; - `Plan` exposes Generate / Improve Plan beside the Plan itself; diff --git a/design-system/prototypes/todos.html b/design-system/prototypes/todos.html index 2fd7c44e..64dddb63 100644 --- a/design-system/prototypes/todos.html +++ b/design-system/prototypes/todos.html @@ -233,8 +233,8 @@ .todo-card { display: grid; - grid-template-columns: 40px minmax(0, 1fr); - min-height: 106px; + grid-template-columns: 44px minmax(0, 1fr); + min-height: 48px; overflow: hidden; border: 1px solid var(--border-default); border-radius: 7px; @@ -260,9 +260,11 @@ } .todo-card-content { - display: block; + display: flex; + flex-direction: column; + justify-content: center; min-width: 0; - padding: 12px; + padding: 8px 10px; background: transparent; text-align: left; } @@ -271,8 +273,6 @@ background: var(--bg-hover); } - .todo-card-top, - .todo-card-meta, .detail-kicker, .document-meta, .work-row-meta { @@ -281,10 +281,6 @@ gap: 7px; } - .todo-card-top { - justify-content: space-between; - } - .todo-state { display: inline-flex; align-items: center; @@ -311,7 +307,6 @@ color: var(--success); } - .runtime-badge, .artifact-badge { display: inline-flex; align-items: center; @@ -323,21 +318,6 @@ font-weight: 700; } - .runtime-badge.running { - background: var(--signal-field); - color: var(--signal-foreground); - } - - .runtime-badge.attention { - background: var(--warning-field); - color: var(--warning); - } - - .runtime-badge.review { - background: var(--brand-field); - color: var(--brand); - } - .runtime-dot { width: 6px; height: 6px; @@ -345,7 +325,7 @@ background: currentColor; } - .runtime-badge.running .runtime-dot { + .todo-operational.running .runtime-dot { animation: activity-pulse 1.8s ease-in-out infinite; } @@ -354,35 +334,31 @@ 50% { opacity: 1; } } - .todo-name { - display: block; - margin-top: 8px; - font-size: 13px; - font-weight: 650; - line-height: 1.35; - } - - .todo-preview { + .todo-excerpt { display: -webkit-box; - margin-top: 5px; overflow: hidden; - color: var(--text-tertiary); - font-size: 11px; - line-height: 1.45; + font-size: 13px; + font-weight: 550; + line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } - .todo-card-meta { - margin-top: 10px; - color: var(--text-tertiary); - font-size: 9px; + .todo-operational { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-size: 11px; + line-height: 16px; } - .todo-card-meta span + span::before { - margin-right: 7px; - content: "·"; - } + .todo-operational.running { color: var(--signal-foreground); } + .todo-operational.attention { color: var(--warning); } + .todo-operational.review { color: var(--brand); } .lane-orbit { width: 12px; @@ -941,10 +917,7 @@
@@ -958,10 +931,7 @@
@@ -975,30 +945,24 @@
@@ -1012,10 +976,7 @@
@@ -1039,7 +1000,7 @@
Readytodo-profile-defaultsUpdated 18m ago
-

Model profile defaults per project

+

Todo detail

@@ -1330,7 +1291,7 @@

Lifecycle

const todoFilterEmptyTitle = document.querySelector("[data-todo-filter-empty-title]"); const boardRoute = document.querySelector("[data-board-route]"); const detailRoute = document.querySelector("[data-detail-route]"); - const detailTitle = document.querySelector("[data-detail-title]"); + const detailHeading = document.querySelector("[data-detail-heading]"); const detailStatus = document.querySelector("[data-detail-status]"); const detailId = document.querySelector("[data-detail-id]"); const detailUpdated = document.querySelector("[data-detail-updated]"); @@ -1363,32 +1324,22 @@

Lifecycle

return value.replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]); } - function displayLabel(sample) { - const firstLine = (sample?.content || "").split(/\r?\n/).find((line) => line.trim()); - const cleaned = firstLine?.trim().replace(/^(?:#{1,6}|>|[-+*]|\d+[.)])\s+/, "").replace(/^\[[ xX]\]\s*/, ""); - return cleaned || `Todo ${(sample?.id || "unknown").slice(0, 8)}`; - } - - function contentRemainder(content) { - const lines = (content || "").split(/\r?\n/); - const first = lines.findIndex((line) => line.trim()); - return first < 0 ? "" : lines.slice(first + 1).join("\n").trim(); - } - - function contentPreview(content) { - return contentRemainder(content) + function contentExcerpt(content) { + const compact = (content || "") .split(/\r?\n/) .map((line) => line.trim().replace(/^(?:#{1,6}|>|[-+*]|\d+[.)])\s+/, "").replace(/^\[[ xX]\]\s*/, "")) .filter(Boolean) - .join(" "); + .join(" ") + .replace(/\s+/g, " ") + .trim(); + return compact.length <= 80 ? compact : `${compact.slice(0, 79).trimEnd()}…`; } function contentBodyHtml(content) { - const blocks = contentRemainder(content).split(/\n\s*\n/).filter(Boolean); - if (!blocks.length) return '

No additional detail yet.

'; + const blocks = (content || "").split(/\n\s*\n/).filter(Boolean); return blocks.map((block) => { const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const heading = lines[0]?.match(/^#{2,6}\s+(.+)$/); + const heading = lines[0]?.match(/^#{1,6}\s+(.+)$/); if (heading && lines.length === 1) return `

${escapeHtml(heading[1])}

`; if (lines.every((line) => /^[-+*]\s+/.test(line))) return `
    ${lines.map((line) => `
  • ${escapeHtml(line.replace(/^[-+*]\s+/, ""))}
  • `).join("")}
`; if (lines.every((line) => /^\d+[.)]\s+/.test(line))) return `
    ${lines.map((line) => `
  1. ${escapeHtml(line.replace(/^\d+[.)]\s+/, ""))}
  2. `).join("")}
`; @@ -1426,7 +1377,7 @@

Lifecycle

function dynamicSample(record) { const running = record.lane === "in_progress"; - const label = displayLabel(record); + const excerpt = contentExcerpt(record.content); return { ...record, status: running ? "In Progress" : "Idea", @@ -1435,7 +1386,7 @@

Lifecycle

showPlanPrompt: false, primary: running ? "Open Session" : "Run now", work: running ? "running" : "none", - session: running ? { id: record.sessionId, title: label, summary: "Lead · started directly from quick capture", state: "Running" } : undefined, + session: running ? { id: record.sessionId, title: excerpt, summary: "Lead · started directly from quick capture", state: "Running" } : undefined, }; } @@ -1466,24 +1417,20 @@

Lifecycle

} function cardMarkup(sample) { - const label = displayLabel(sample); - const tone = sample.status === "Done" ? "done" : sample.status === "In Progress" ? "running" : ""; + const excerpt = contentExcerpt(sample.content); const runtime = sample.work === "attention" ? "needs_you" : sample.work === "running" ? "running" : sample.work === "review" ? "review" : ""; - const badge = sample.work === "attention" - ? 'Needs you' + const operational = sample.work === "attention" + ? 'Needs you' : sample.work === "running" - ? 'Running now' + ? 'Running now' : sample.work === "review" - ? 'Ready to review' + ? 'Ready to review' : ""; - const meta = sample.work === "none" ? "No execution" : sample.work === "running" ? "Lead · 1 Session" : sample.work === "review" ? "Lead → Build · Result ready" : "Captured"; return `
- +
`; } @@ -1536,7 +1483,6 @@

Lifecycle

if (!sample) return false; currentTodo = sample; const workMode = sample.work || "none"; - detailTitle.textContent = displayLabel(sample); detailStatus.textContent = sample.status; detailStatus.dataset.tone = sample.status === "Done" ? "done" : sample.status === "In Progress" ? "running" : ""; detailId.textContent = sample.id; @@ -1565,7 +1511,7 @@

Lifecycle

workDiscussion.querySelector("[data-work-discussion-state]").textContent = sample.discussion.state; } if (sample.session) { - workSession.href = `./session.html?todo=${encodeURIComponent(sample.id)}&session=${encodeURIComponent(sample.session.id)}&title=${encodeURIComponent(displayLabel(sample))}`; + workSession.href = `./session.html?todo=${encodeURIComponent(sample.id)}&session=${encodeURIComponent(sample.session.id)}&title=${encodeURIComponent(contentExcerpt(sample.content))}`; workSession.querySelector("[data-work-session-title]").textContent = sample.session.title; workSession.querySelector("[data-work-session-summary]").textContent = sample.session.summary; const sessionState = workSession.querySelector("[data-work-session-state]"); @@ -1587,7 +1533,7 @@

Lifecycle

detailRoute.hidden = false; detailOpenedFromBoard = updateHistory; if (updateHistory) history.pushState({ todo: trigger.dataset.todoId }, "", `#todo=${encodeURIComponent(trigger.dataset.todoId)}`); - requestAnimationFrame(() => detailTitle.focus()); + requestAnimationFrame(() => detailHeading.focus()); } function closeTodo(updateHistory = true) { @@ -1616,13 +1562,13 @@

Lifecycle

record.sessionId = sessionId; localStorage.setItem(storedTodoKey, JSON.stringify(records)); } - location.href = rememberRunningSession({ id: currentTodo.id, title: displayLabel(currentTodo), sessionId }); + location.href = rememberRunningSession({ id: currentTodo.id, title: contentExcerpt(currentTodo.content), sessionId }); }); detailDiscuss?.addEventListener("click", () => { if (!currentTodo) return; const sessionId = currentTodo.discussion?.id || `sess-${currentTodo.id}-discussion-1`; - location.href = `./session.html?todo=${encodeURIComponent(currentTodo.id)}&session=${encodeURIComponent(sessionId)}&title=${encodeURIComponent(displayLabel(currentTodo))}&kind=discussion`; + location.href = `./session.html?todo=${encodeURIComponent(currentTodo.id)}&session=${encodeURIComponent(sessionId)}&title=${encodeURIComponent(contentExcerpt(currentTodo.content))}&kind=discussion`; }); window.addEventListener("popstate", () => { @@ -1658,16 +1604,16 @@

Lifecycle

const sessionId = `sess-${id}-work-1`; const records = readStoredTodos(); const record = { id, content, lane: runNow ? "in_progress" : "idea", ...(runNow ? { sessionId } : {}) }; - const label = displayLabel(record); + const excerpt = contentExcerpt(record.content); records.unshift(record); localStorage.setItem(storedTodoKey, JSON.stringify(records.slice(0, 8))); input.value = ""; if (runNow) { - location.href = rememberRunningSession({ id, title: label, sessionId }); + location.href = rememberRunningSession({ id, title: excerpt, sessionId }); return; } renderStoredTodos(); - showToast(`Saved “${label}” to Ideas`); + showToast(`Saved “${excerpt}” to Ideas`); }); renderStoredTodos(); diff --git a/packages/agent-core/src/todos/service.ts b/packages/agent-core/src/todos/service.ts index cdf2de61..d14047cb 100644 --- a/packages/agent-core/src/todos/service.ts +++ b/packages/agent-core/src/todos/service.ts @@ -1,6 +1,6 @@ import { PROJECT_STATE_DIR_NAME, - projectTodoDisplayLabel, + projectTodoContentExcerpt, type CreateProjectTodoSessionInput, type CreateProjectTodoSessionResponse, type AttachmentDescriptor, @@ -188,8 +188,8 @@ export class ProjectTodoService { workspaceRoot: this.workspaceRoot, agentName: request.entry === "discussion" ? "discussion" : "lead", title: request.entry === "discussion" - ? `Discussion: ${projectTodoDisplayLabel(todo.content, todo.id)}` - : projectTodoDisplayLabel(todo.content, todo.id), + ? `Discussion: ${projectTodoContentExcerpt(todo.content)}` + : projectTodoContentExcerpt(todo.content), source, }); await this.#sessions.acceptMessage({ @@ -253,7 +253,7 @@ export class ProjectTodoService { ({ sessionId } = await this.#sessions.createRootSession({ workspaceRoot: this.workspaceRoot, agentName: "lead", - title: projectTodoDisplayLabel(todo.content, todo.id), + title: projectTodoContentExcerpt(todo.content), source: { kind: "todo", todoId: todo.id, entry: "work" }, })); try { diff --git a/packages/protocol/src/project-todos.test.ts b/packages/protocol/src/project-todos.test.ts index f8d99b5d..03263b3a 100644 --- a/packages/protocol/src/project-todos.test.ts +++ b/packages/protocol/src/project-todos.test.ts @@ -1,16 +1,26 @@ import { describe, expect, test } from "bun:test"; -import { PROJECT_TODO_DISPLAY_LABEL_MAX_LENGTH, projectTodoDisplayLabel } from "./project-todos"; +import { PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH, projectTodoContentExcerpt } from "./project-todos"; -describe("projectTodoDisplayLabel", () => { - test("derives one stable display label from canonical Markdown", () => { - expect(projectTodoDisplayLabel("\n## Ship offline mode\n\nAcceptance details", "todo-1")) - .toBe("Ship offline mode"); - expect(projectTodoDisplayLabel("- [x] Fix the type\nMore detail")) - .toBe("Fix the type"); +describe("projectTodoContentExcerpt", () => { + test("compacts the beginning of canonical Markdown without inferring a title", () => { + expect(projectTodoContentExcerpt("\n## Goal\n\nResearch and improve codemap\n\n- Keep evidence")) + .toBe("Goal Research and improve codemap Keep evidence"); + expect(projectTodoContentExcerpt("- [x] Fix the type\n\tMore detail")) + .toBe("Fix the type More detail"); }); - test("bounds the projection and uses the Todo id only for empty defensive input", () => { - expect(projectTodoDisplayLabel("x".repeat(200))).toHaveLength(PROJECT_TODO_DISPLAY_LABEL_MAX_LENGTH); - expect(projectTodoDisplayLabel(" \n ", "12345678-rest")).toBe("Todo 12345678"); + test("bounds the excerpt and does not invent content", () => { + expect(projectTodoContentExcerpt("x".repeat(200))).toHaveLength(PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH); + expect(projectTodoContentExcerpt(" \n ")).toBe(""); + }); + + test("counts Unicode code points without splitting surrogate pairs", () => { + const exactLimit = `${"a".repeat(78)}😀b`; + expect(projectTodoContentExcerpt(exactLimit)).toBe(exactLimit); + expect(Array.from(projectTodoContentExcerpt(exactLimit))).toHaveLength(PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH); + + const truncated = projectTodoContentExcerpt(`${exactLimit}c`); + expect(truncated).toBe(`${"a".repeat(78)}😀…`); + expect(Array.from(truncated)).toHaveLength(PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH); }); }); diff --git a/packages/protocol/src/project-todos.ts b/packages/protocol/src/project-todos.ts index 40d5e03c..53f87fe2 100644 --- a/packages/protocol/src/project-todos.ts +++ b/packages/protocol/src/project-todos.ts @@ -1,7 +1,7 @@ import type { AttachmentDescriptor } from "./attachments"; export const PROJECT_TODO_CONTENT_MAX_LENGTH = 20_000; -export const PROJECT_TODO_DISPLAY_LABEL_MAX_LENGTH = 120; +export const PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH = 80; export const PROJECT_TODO_REJECTION_REASON_MAX_LENGTH = 4_000; export type ProjectTodoStatus = "idea" | "ready" | "in_progress" | "done" | "rejected"; @@ -112,22 +112,19 @@ export interface CreateProjectTodoSessionResponse extends ProjectTodoResponse { readonly sessionId: string; } -/** - * Deterministic, display-only label derived from canonical Todo Markdown. - * It is never persisted and never becomes a second editable Todo field. - */ -export function projectTodoDisplayLabel(content: string, todoId?: string): string { - const firstLine = content +/** Compact, display-only prefix of canonical Todo Markdown. */ +export function projectTodoContentExcerpt(content: string): string { + const compact = content .split(/\r?\n/u) .map((line) => line.trim()) - .find((line) => line.length > 0); - const cleaned = firstLine - ?.replace(/^(?:#{1,6}|>|[-+*]|\d+[.)])\s+/u, "") - .replace(/^\[[ xX]\]\s*/u, "") + .map((line) => line + .replace(/^(?:#{1,6}|>|[-+*]|\d+[.)])\s+/u, "") + .replace(/^\[[ xX]\]\s*/u, "")) + .filter((line) => line.length > 0) + .join(" ") .replace(/\s+/gu, " ") .trim(); - const emptyContentLabel = todoId === undefined ? "Untitled Todo" : `Todo ${todoId.slice(0, 8)}`; - if (!cleaned) return emptyContentLabel; - if (cleaned.length <= PROJECT_TODO_DISPLAY_LABEL_MAX_LENGTH) return cleaned; - return `${cleaned.slice(0, PROJECT_TODO_DISPLAY_LABEL_MAX_LENGTH - 1).trimEnd()}…`; + const characters = Array.from(compact); + if (characters.length <= PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH) return compact; + return `${characters.slice(0, PROJECT_TODO_CONTENT_EXCERPT_MAX_LENGTH - 1).join("").trimEnd()}…`; } From a379417863e981fd6f711883c6f97f2e9ba4949c Mon Sep 17 00:00:00 2001 From: bo Date: Mon, 3 Aug 2026 22:54:07 +0800 Subject: [PATCH 08/11] feat(workbench): refine project inventory and Todo surfaces --- apps/web/src/routes/automations.tsx | 56 +-- apps/web/src/routes/project-sessions.tsx | 59 +-- apps/web/src/routes/project-todos.test.tsx | 6 +- apps/web/src/routes/project-todos.tsx | 10 +- apps/web/src/styles/visual-contract.test.ts | 35 ++ design-system/MASTER.md | 2 +- design-system/pages/todos.md | 44 ++- design-system/prototypes/todos.html | 388 ++++++++++++++------ 8 files changed, 410 insertions(+), 190 deletions(-) diff --git a/apps/web/src/routes/automations.tsx b/apps/web/src/routes/automations.tsx index 5f329d34..9ccfd0ee 100644 --- a/apps/web/src/routes/automations.tsx +++ b/apps/web/src/routes/automations.tsx @@ -87,34 +87,36 @@ export function AutomationsRoute() { }; return ( -
-
-
- - +
+
+ + +
+
+
+ {inventory.isLoading ?

Loading Automations…

: null} + {inventory.error ?

Failed to load Automations

: null} + {!inventory.isLoading && !inventory.error && filtered.length === 0 ?

{automationInventoryEmptyMessage(inventory.data?.length ?? 0)}

: null} + {(["needs-attention", "scheduled", "paused", "inactive"] as const).map((group) => groups[group].length > 0 ? ( + + ) : null)}
- {inventory.isLoading ?

Loading Automations…

: null} - {inventory.error ?

Failed to load Automations

: null} - {!inventory.isLoading && !inventory.error && filtered.length === 0 ?

{automationInventoryEmptyMessage(inventory.data?.length ?? 0)}

: null} - {(["needs-attention", "scheduled", "paused", "inactive"] as const).map((group) => groups[group].length > 0 ? ( - - ) : null)}
setCreating(false)} slug={slug} />
diff --git a/apps/web/src/routes/project-sessions.tsx b/apps/web/src/routes/project-sessions.tsx index 650c087e..20686222 100644 --- a/apps/web/src/routes/project-sessions.tsx +++ b/apps/web/src/routes/project-sessions.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Filter, Plus, Search } from "lucide-react"; +import { ChevronDown, Filter, Plus, Search } from "lucide-react"; import { projectTodoContentExcerpt, type SessionFamilyActivity } from "@archcode/protocol"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useCreateSession } from "../api/mutations"; @@ -123,10 +123,9 @@ export function ProjectSessionsRoute() { }); return ( -
-
-
-