Skip to content

Latest commit

 

History

History
327 lines (279 loc) · 21.7 KB

File metadata and controls

327 lines (279 loc) · 21.7 KB

Relay Desktop GUI — gpui Rewrite (Design & Development Plan)

Status: P1–P8 implemented (builds; interactive parity verification ongoing) Supersedes: macos/ (SwiftUI) — kept in parallel until the gpui app reaches feature parity. Single source of truth for the GUI rewrite. The product spec remains docs/SPEC.md.

1. Motivation

The macOS GUI is currently SwiftUI (macos/). We are rebuilding it in Rust with gpui (the GPU-accelerated UI framework from the Zed team). Goals:

  • One language across the stack. CLI, TUI, MCP server, and now the GUI are all Rust, so domain types and (potentially) logic are shared instead of re-implemented in Swift.
  • Native performance. gpui renders on Metal; uniform_list virtualizes the 10k-task list to hit the <100ms target.
  • Lower maintenance surface. No SPM/Swift toolchain, no duplicated model layer.

2. Architectural decisions (confirmed)

Decision Choice Rationale
Data access Through the relayd daemon (relay_core::daemon::DaemonClient, auto-spawned) — tool name + JSON over a per-DB Unix socket. Live updates via a subscribe connection (daemon push). turso's experimental_multiprocess_wal does not serialize concurrent multi-process access (a held connection exclusively locks -wal). The earlier fix (GUI opens the DB directly with connect-on-demand + app-level flock, live updates via a notify fs-watch) worked but kept per-op open cost and left CLI/MCP contending. Superseded by a single DB-owning daemon (relayd): only it opens the DB (one long-lived connection), and GUI/CLI/MCP are thin socket clients. See docs/DAEMON.md.
UI components gpui-component (longbridge) gpui core ships no text input, dropdown, or table. gpui-component provides Input (with IME), Dropdown, virtualized Table/List, theming — close to a Linear-like app out of the box.
gpui dependency Pinned git rev of zed-industries/zed (same rev gpui-component tracks) main and crates.io 0.2.2 diverge (Application::new() vs gpui_platform::application()). Pinning a rev avoids surprise breakage; gpui-component already tracks main via git.
SwiftUI app Kept in parallel, retired only after parity Low risk; allows rollback. macos/ stays buildable during the migration.

3. System context

┌─────────────┐   tool name + JSON over a per-DB Unix socket   ┌──────────────────────┐
│ gpui GUI    │ ─────────────────────────────────────────────▶ │ relayd (relayd/ crate)│
│ (gui/ crate)│ ◀──── ChangeEvent push (subscribe conn) ─────── │  owns tasks.turso.db  │
└─────────────┘                                                 │  (one long-lived conn)│
        ▲                                                       └──────────────────────┘
        │ same daemon: `rly` CLI/TUI, `rly serve` (MCP for Claude Code)   │
        └─────────────────────────────────────────────────────────────────┘

The GUI never opens the SQLite/turso file. It launches/supervises the daemon (launchd, reusing the SwiftUI DaemonManager design) and speaks MCP to it. On any write, the daemon broadcasts resources/list_changed; the GUI re-fetches affected lists.

4. New workspace layout

relay/
├── shared/                     # existing — cross-crate types (RFC3339 time helpers)
├── cli/                        # existing — `rly` (CLI + TUI + MCP server)
├── gui/                        # NEW — `relay-gui` binary (gpui app)
│   ├── Cargo.toml
│   └── src/
│       ├── main.rs             # app bootstrap, window, menus, key bindings
│       ├── app.rs              # root view (3-pane), global AppState entity
│       ├── theme.rs            # colors/spacing tokens (status & priority palettes)
│       ├── mcp/                # MCP HTTP client (rmcp client transport)
│       │   ├── client.rs       # connect, call_tool<T>, resources/list_changed stream
│       │   └── daemon.rs       # launchd supervision + health probe (port 7777)
│       ├── store/              # data layer: entities holding fetched state
│       │   ├── mod.rs
│       │   ├── store.rs        # Store entity: tasks/projects/tags + refresh on notify
│       │   └── models.rs       # serde models mirroring cli/src/models (or re-used)
│       └── views/
│           ├── sidebar.rs      # filters / projects / saved views
│           ├── task_list.rs    # uniform_list, grouped by status, keyboard nav
│           ├── task_row.rs     # status icon, title, priority, due date
│           ├── task_detail.rs  # metadata, description, tags, subtasks, comments
│           ├── task_editor.rs  # create/edit form (gpui-component Input/Dropdown)
│           ├── popovers.rs     # status/priority/due/project/assignee pickers
│           ├── quick_add.rs    # Cmd+Shift+Space floating quick-add
│           └── setup.rs        # daemon status / endpoint / LaunchAgent panel
└── docs/GUI_GPUI.md            # this file

Toolchain (load-bearing)

gpui at the pinned Zed rev uses std::hint::cold_path, stabilized in Rust 1.95.0. The whole workspace is therefore pinned to 1.95.0 via rust-toolchain.toml (the same version Zed's rev pins). Building with ≤1.94 fails with E0658: unstable feature cold_path.

Icon assets (load-bearing)

gpui-component's Icon/IconName SVGs load through the app's AssetSource, not through gpui_component::init. The app must register gpui-component-assets's Assets via gpui_platform::application().with_assets(gpui_component_assets::Assets). Without it every Icon renders blank (the SVG can't be found) while hand-built div shapes and emoji text still show — a confusing partial-blank UI. The gpui-component-assets dep is pinned to the same git rev as gpui-component. (Bundled fonts are separate: they go through cx.text_system().add_fonts, not the AssetSource.)

MCP client API (resolved — rmcp 1.8, client side)

rmcp ships a first-class streamable-HTTP client; no hand-rolled JSON-RPC needed.

rmcp = { version = "1.8", features = [
    "client", "reqwest", "transport-streamable-http-client-reqwest",
] }
  • Transport: StreamableHttpClientTransport::from_uri("http://127.0.0.1:7777/mcp").
  • Connect: handler.serve(transport).await? → a RunningService<RoleClient, _> (the peer).
  • Call a tool: client.call_tool(CallToolRequestParams::new("task_list").with_arguments(map)).await?; read JSON from result.structured_content (preferred) or result.content[..].as_text().text.
  • Live updates: implement rmcp::handler::client::ClientHandler and override on_resource_list_changed(&self, ctx) — fires on the SSE stream when the daemon broadcasts. Bridge it to gpui by holding a channel sender / weak entity handle in the handler and waking the UI to re-fetch. The whole client API is async and needs a tokio runtime.

Live-update opt-in (resolved in P2)

The daemon only registers a session for resources/list_changed broadcasts when that session calls a resource handler (resources/list / resources/read) — see cli/src/mcp/notify.rs + handler.rs store_peer. A client that calls only tools is never notified. The GUI therefore calls resources/list once right after connecting (mcp/client.rs) to opt into live updates. Validated end-to-end: an external task_create fires on_resource_list_changedStore::reload.

Daemon version caveat: live updates require the daemon and GUI to use a compatible rmcp. A daemon built with rmcp 1.5 returns EOF on the client's standalone GET SSE stream (no notifications); rmcp 1.8 on both sides works. The bundled rly (P7) is built from this tree, so they always match. Connect + fetch works regardless of skew; only push notifications are affected.

Crate boundary for models

cli/src/models types derive Serialize/Deserialize. To avoid duplication we will try to reuse them from relay-cli as a library dependency (the crate already exposes lib.rs). If pulling in relay-cli drags heavy deps (turso, axum, rmcp server) into the GUI build, we fall back to a thin models.rs in gui/ that mirrors the JSON shape. Decision is made in Phase 1 after measuring build impact.

5. Feature parity checklist (from SwiftUI app)

  • 3-pane layout: sidebar / task list / detail
  • Sidebar: All Tasks, Inbox, Completed filters; Projects (counts); Saved Views
  • Task list grouped by status; virtualized (uniform_list)
  • Inline status & priority editing (chips in the detail pane)
  • Task detail: title/ID/parent, metadata, description, tags, subtasks, links, comments
  • Create task (inline form: title) → task_create
  • Complete / delete task; status & priority changes
  • Search (FTS via search tool) + clearable override
  • Saved views list + apply (view_list / view_apply)
  • Quick add (Cmd-N opens the inline create form)
  • Setup panel: daemon status, MCP endpoint, client config JSON
  • Menus + Quit (Cmd-Q), New Task (Cmd-N)
  • Live updates via resources/list_changed
  • .app bundle + bundled rly binary, ad-hoc signing (gui/bundle.sh)

Deferred / not yet at full SwiftUI parity (follow-ups):

  • Markdown rendering of descriptions; comment composer (write)
  • Global quick-add panel (Cmd+Shift+Space) with click-to-cycle meta-chip pickers (status / priority / assignee / project) + Cmd+Enter create
  • System-wide quick-capture panels (Raycast-style) — two configurable global shortcuts (config.toml quick_capture_hotkey default cmd-ctrl-t, new_task_hotkey default cmd-ctrl-n) that summon a capture panel from any app. Each opens its own floating window (WindowKind::PopUp → a macOS nonactivating NSPanel above the front app; the main Relay window is not brought forward). Quick-add = title + priority; new-task = title + description + priority + hand-off. Enter/⌘Enter creates (fire-and-forget via the shared daemon) and closes; Esc closes. Registered via the global-hotkey crate (macOS Carbon RegisterEventHotKey); presses bridge into gpui to open the panel. Both shortcuts editable in Settings › Quick capture. See gui/src/views/capture.rs, gui/src/hotkey.rs, and the bridge in gui/src/main.rs.
  • Board (Kanban) view with drag-and-drop between status columns
  • Command palette (Cmd-K) keyboard navigation (↑/↓ move, Enter confirms)
  • Inline popovers for due date (quick-add "+ Due" still display-only); tag editing
  • Drag-to-reorder within a list; main task-list keyboard navigation
  • launchd install from the app; app icon; codesigning + notarization

Design-fidelity pass (2026-06-30)

A screenshot-diff pass against docs/design/* (see the screenshot harness note in the agent memory) fixed several mockup divergences:

  • Icon assets registered — all IconName SVGs were blank; see "Icon assets (load-bearing)" above.
  • Task-row anatomy — order is now checkbox · status · id · title · …priority (priority moved from the left to the right meta cluster; status moved ahead of the id), matching Task List.
  • Detail status picker — ordered by workflow (Backlog→…→Cancelled) via TaskStatus::WORKFLOW_ORDER; the list grouping keeps the active-first ORDER.
  • Project Overview — Milestones and Documents render side by side (1fr 1fr grid) with Updates full-width below, per Project Overview.
  • In-progress status glyph — a clipped half-disc (overflow_hidden on a rounded inner box), matching the design's partial pie.

Known remaining (data/perf-driven, not rendering bugs): the projects list omits per-row health badges and lead avatars when that data is absent.

Design-fidelity pass 2 (shared property pickers + creation flow)

A second mockup-driven pass (docs/design/{Property Pickers,New Task,New Task - Add Sub-task,Quick Add,Review Inbox,Settings,Task Detail}.dc.html) reworks the editing/creation surfaces around a single shared component:

  • Shared property pickers (views/property_picker.rs) — one popover family for status / priority / assignee / due-date (presets + month calendar) / project, anchored under a property chip with full keyboard nav (↑↓ move, number-key jump, ↵ confirm, esc close, type-to-filter). Replaces the three duplicated status/priority renderings (detail chips, bulk squares, quick-add cycle chips). All edits funnel through task_update (assignee/project/priority/status/due_date/ milestone_id) — no core change needed for the pickers.
  • Task Detail — meta chips become the edit affordance (open the picker); the always-on STATUS/PRIORITY chip grids are removed from the actions box; title and description gain inline "Edit" affordances.
  • Quick Add — meta chips open the shared pickers (was click-to-cycle); the active chip gets the selected/accent style; the "+ Due" chip is now functional.
  • New Task modal (views/new_task.rs, ⌘N) — full 720px creation modal: title, description, property grid (shared pickers), a "Hand off to Relay AI" toggle (sets assignee=ai), and an inline sub-task composer (↵ adds + opens next, ⇥ for per-subtask mini pickers). Replaces the title-only inline create form. Quick Add (⌘⇧Space) stays as the lightweight path; list-group "+" reuses Quick Add with the target status/milestone pre-filled.
  • Review Inbox — card wall → dense virtualized list rows (compact toolbar header, "Awaiting human confirmation" banner, AI avatar, id+title, one-line summary, PR link indicator, relative timestamp, hover Approve/Send-back).
  • Settings → Coding tools — single-tool form → multi-tool gallery (selectable cards, DEFAULT badge, + Add tool, Save/Duplicate/Delete). Requires core/src/api.rs coding_tools_view/coding_tools_save to return/persist the full tools map instead of collapsing to one (the CodingToolsConfig.tools BTreeMap already supports it; the GUI save previously dropped sibling tools).

Module layout (post-split)

app.rs was split from a ~7,200-line monolith into per-screen modules under gui/src/views/ (each an impl RelayApp block, or free functions for the presentational ones). app.rs (~3,150 lines) now holds the RelayApp struct, the view-state enums, the core state/IO methods, the shared free helpers, and the Render dispatch. Cross-module access works because the struct fields and the view-invoked methods are pub(crate).

  • views/sidebar.rs — sidebar (workspace header / Tasks / Projects / Views / footer)
  • views/toolbar.rs — breadcrumb, project tab bar, view-options + filter popovers, selection (bulk) bar, issues toolbar
  • views/detail.rs — full-page task detail + comment composer
  • views/project.rs — project list / overview (burnup) / create-edit form
  • views/overlays.rs — command palette + quick-add panel
  • views/settings.rs — settings / setup pane
  • views/board.rs, views/review_inbox.rs, views/task_list.rs, views/title_bar.rs, views/states.rs — as before

6. MCP tool surface used by the GUI

Reads: task_list, task_get, project_list, tag_list, comment_list, task_list_links, project_list_links, milestone_list, project_list_updates, project_progress, view_list, view_apply, search. Writes: task_create, task_update, task_move, task_complete, task_reorder, task_delete, task_add_tag, task_remove_tag, task_add_dependency, task_remove_dependency, project_create, project_update, project_archive, project_delete, project_add_link, project_remove_link, tag_create, comment_create, view_create, view_delete, task_add_link, task_remove_link.

Project revamp tools (F2+)

The project-workspace revamp (docs/SPEC.md §3) adds project-level document links and a working directory, plus (later phases) updates, milestones, and progress:

  • project_add_link(project, link_type, url, title?) / project_list_links(project) / project_remove_link(id) — document links on a project (mirrors task_*_link).
  • project_create / project_update gain a working_dir field (local checkout path).
  • project_post_update(project, health, body, author) / project_list_updates(project) (newest first) / project_delete_update(id) — Linear-style status updates (F3). healthon_track / at_risk / off_track; body is markdown. Distinct from project_update (which edits project metadata).
  • milestone_create(project, name, description?, target_date?) / milestone_list(project) / milestone_update(id, name?, description?, target_date?, status?) / milestone_delete(id) — project milestones (F4). task_create / task_update gain a milestone_id field so an issue can be placed in a milestone. The Issues tab can group by Status or Milestone.
  • project_progress(project, from?, to?) — burnup series ({day, scope, started, completed} per day) for a project (F5). Recomputed analytically from task timestamps and cached in project_progress_snapshot; the Overview pane renders it with a gpui-component AreaChart. Task gains started_at (set on first InProgress); the HTTP daemon runs a daily snapshot job.

7. gpui implementation notes

  • Entry point: pin a Zed rev; verify Application::new() vs gpui_platform::application() against that rev before writing bootstrap.
  • State/reactivity: Entity<Store> holds fetched data; views cx.observe(&store) and re-render on cx.notify(). No automatic dependency tracking — every mutation calls notify.
  • Long list: uniform_list("tasks", count, cx.processor(|this, range, _, _| ...)).
  • Async: cx.spawn(async move |weak, cx| { cx.background_executor().spawn(io).await; weak.update(cx, ...) }). MCP calls and the list_changed subscription run on background tasks, update entities on the UI thread.
  • Text input / dropdowns / table: from gpui-component, not hand-rolled.
  • Menus: cx.set_menus([...]), actions via actions!, KeyBinding::new(...).
  • Packaging: cargo-bundle (Zed fork, zed-deploy branch) → .app; bundle rly at Relay.app/Contents/MacOS/rly; reuse SwiftUI launchd plist scheme (co.driveshaft.relay.daemon).

8. Phased delivery & branches

Each phase is a separate branch (stacked: each off the previous), all implemented and building. Branches: feat/gui-foundationfeat/gui-data-layerfeat/gui-task-listfeat/gui-task-detailfeat/gui-editingfeat/gui-search-viewsfeat/gui-packagingfeat/gui-cutover.

Phase Branch Deliverable
P1 Foundation feat/gui-foundation gui/ crate in workspace; gpui + gpui-component deps build; empty styled window opens; theme tokens; CI builds.
P2 Data layer feat/gui-data-layer MCP HTTP client (call_tool<T>, list_changed stream); daemon probe/supervise; Store entity; models decision; smoke test against a running daemon.
P3 Task list + sidebar feat/gui-task-list Sidebar (filters/projects/views), virtualized task list grouped by status, keyboard nav, live refresh. Read-only.
P4 Task detail feat/gui-task-detail Detail pane: metadata, markdown desc, tags, subtasks, links, comments, activity.
P5 Editing feat/gui-editing Create/edit sheets, inline popovers (status/priority/due/project/assignee), reorder, complete, delete — all writes.
P6 Search + saved views + quick add feat/gui-search-views FTS search, saved views CRUD, Cmd+Shift+Space quick add.
P7 Packaging + setup feat/gui-packaging Menus, shortcuts, Setup window, cargo-bundle .app, bundled rly, launchd, signing; run.sh/install.sh equivalents.
P8 Cutover feat/gui-cutover Docs/README updated, retire macos/ (move to legacy/ or delete) once parity verified.

Phases 3–6 can overlap once P2 lands (independent view work). Sub-agents may own individual views in parallel against the stable Store API.

9. Risks

  • gpui API churn — mitigated by pinning a rev and isolating bootstrap in main.rs.
  • Thin docs — learn from zed/crates/gpui/examples/ and gpui-component examples.
  • IME / Japanese input — rely on gpui-component Input; verify early in P2/P5.
  • rmcp client over HTTP — confirm rmcp exposes a streamable-http client (the daemon uses the server side). If not, use a plain reqwest + SSE JSON-RPC client. Resolved in P2.
  • Build weight of depending on relay-cli — measured in P1/P2; fallback is local models.

## ビルドキャッシュ管理(数十GB対策)

gpui は zed monorepo(git) をビルドし ~1,000 クレート(wgpu/font-kit 含む)をコンパイルするため、
`target/debug` が数十GB化しやすい。緩和策:

- `Cargo.toml [profile.dev] debug = "line-tables-only"` で debuginfo を削減済み(バックトレースは維持)。
- 古い成果物の定期削除に `cargo-sweep` を使う:
  - インストール: `cargo install cargo-sweep`
  - 30日より古い成果物を削除: `cargo sweep --time 30`
  - (実行はユーザー判断。破壊的なので自動化しない。)
- 抜本策の検討経緯は `docs/superpowers/specs/2026-07-25-tauri-poc-design.md` を参照。