feat(renderer): clone agent-orchestrator shell and inspector

This commit is contained in:
yyovil 2026-06-11 16:45:00 +05:30 committed by GitHub
parent 0021aa6097
commit e493de6ad7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 8261 additions and 1486 deletions

View File

@ -4,8 +4,14 @@ Read and follow [`AGENTS.md`](AGENTS.md) for repository layout, commands, coding
## Design System
Always read [`DESIGN.md`](DESIGN.md) before making any visual or UI decision.
All fonts, colors, spacing, layout, the orchestrator-led flow, the topbar, and the
spawn modal are defined there. The UI matches **emdash** with one main orchestrator and
a refined-blue accent. Do not deviate without explicit user approval. In QA/review,
flag any renderer code that doesn't match DESIGN.md.
Always read [`DESIGN.md`](DESIGN.md) before making any visual or UI decision —
**start with the "clone agent-orchestrator verbatim" banner at the top**, which
governs the current look.
The renderer **clones the agent-orchestrator web app verbatim**
(`~/Projects/agent-orchestrator/packages/web/src`) in looks and design, with a
refined-blue accent and the terminal keeping its own palette. This **supersedes the
older "match emdash" framing** in DESIGN.md (per explicit user decision 2026-06-10).
Build new UI from shadcn primitives (`components/ui/*`) where a component fits. Do not
deviate without explicit user approval. In QA/review, flag any renderer code that
diverges from **agent-orchestrator** — do **not** re-flag emdash mismatches.

View File

@ -5,6 +5,43 @@
> - Radix/shadcn + xterm, in `frontend/src/renderer`). Read this before any visual
> or UI change. Created by `/design-consultation` on 2026-06-09.
## ⚠️ Design direction — clone agent-orchestrator verbatim (SUPERSEDES emdash · 2026-06-10)
By explicit user decision (2026-06-10), the renderer **clones the
agent-orchestrator web app verbatim** in looks and design. This **supersedes the
"match emdash" direction** documented in _Aesthetic Direction_ and the palette
sections below — where they conflict, **agent-orchestrator wins**. Do not re-flag
"this doesn't match emdash" in QA/review; flag divergence from **agent-orchestrator**.
- **Reference (the user's own app):** `~/Projects/agent-orchestrator/packages/web/src`
`app/globals.css`, `app/mc-board.css`, `app/mc-sidebar.css`,
`components/{ProjectSidebar,Dashboard,SessionCard,SessionDetailHeader,SessionInspector,StatusBadge}.tsx`.
- **Palette (live in `frontend/src/renderer/styles.css` `:root`):** `--bg #0a0b0d`,
`--bg-1 #15171b`, `--fg #f4f5f7`, `--fg-muted #9ba1aa`, `--fg-passive #646a73`,
hairline white-alpha borders, accent `--accent #4d8dff`; status: working=orange
`#f59f4c`, needs-you=amber `#e8c14a`, mergeable=green `#74b98a`, fail=red `#ef6b6b`.
The sidebar rail is the cooler `#08090b`.
- **Cloned surfaces:** the four-column gradient kanban board, the `ProjectSidebar`
(brand + project disclosure + nested session rows + Settings menu footer), the
session topbar (Kanban back button + identity + breathing `StatusBadge` pill), and
the shared `DashboardTopbar`/`DashboardSubhead` chrome (Coding/Reviews tabs · "N
working" pill · subhead) reused across board/review/PR/settings.
- **Build with shadcn primitives** where a component fits (`components/ui/*`:
dropdown-menu, select, card, table, tooltip, …); agent-orchestrator's own
hand-rolled CSS components are structure/behaviour reference only.
- The one carried-over divergence still holds: the **accent is refined blue**, and
the **terminal keeps its own palette**. Everything else tracks agent-orchestrator.
- **Approved divergence (2026-06-10):** on macOS, a titlebar cluster (sidebar toggle +
back/forward history arrows, `TitlebarNav`) sits beside the traffic lights,
VS Code-style — the web reference has no window chrome, so no analogue exists.
- **Approved divergence (2026-06-10):** the session inspector rail is fully
collapsible, built on the shadcn resizable primitive (`pnpm dlx shadcn add
resizable`, react-resizable-panels v4 `collapsible` panel + imperative API,
user-requested). The panel animates to 0% via a flex-grow transition while the
content keeps a stable min-width (yyork-style, no mid-animation reflow). Toggled
by a `PanelRight` icon button in the session topbar and ⌘⇧B; open state + split
width persist. The AO reference keeps the rail always visible.
## Product Context
- **What this is:** ReverbCode is an Electron desktop app for supervising many parallel
@ -44,6 +81,11 @@ ReverbCode is **orchestrator-led**, which is the one thing that differs from emd
## Aesthetic Direction
> **Superseded (2026-06-10):** see the _Design direction — clone agent-orchestrator
> verbatim_ banner at the top. The emdash framing below is retained for history; the
> live look tracks agent-orchestrator (same flat near-black / hairline family, so most
> of this still reads true).
- **Direction:** match **emdash** exactly — flat, near-black, hairline-bordered,
utilitarian. Industrial control surface, calm chrome, the terminal as the center of gravity.
- **Decoration level:** minimal. Type + 1px hairlines do all the work. No gradients,

View File

@ -16,6 +16,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
@ -105,6 +106,7 @@ func Run() error {
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
Projects: projectsvc.New(store),
Sessions: sessionSvc,
Reviews: reviewsvc.NewInMemory(),
CDC: store,
Events: cdcPipe.Broadcaster,
Activity: lcStack.LCM,

View File

@ -13,6 +13,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review"
)
// APIDeps bundles every service the API layer's controllers depend on.
@ -21,6 +22,7 @@ type APIDeps struct {
Sessions controllers.SessionService
Activity controllers.ActivityRecorder
PRs prsvc.ActionManager
Reviews reviewsvc.Manager
CDC cdc.Source
Events cdcSubscriber
}
@ -32,6 +34,7 @@ type API struct {
projects *controllers.ProjectsController
sessions *controllers.SessionsController
prs *controllers.PRsController
reviews *controllers.ReviewsController
events *EventsController
}
@ -49,6 +52,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
Activity: deps.Activity,
},
prs: &controllers.PRsController{Svc: deps.PRs},
reviews: &controllers.ReviewsController{Svc: deps.Reviews},
events: &EventsController{Source: deps.CDC, Live: deps.Events},
}
}
@ -70,6 +74,7 @@ func (a *API) Register(root chi.Router) {
a.projects.Register(r)
a.sessions.Register(r)
a.prs.Register(r)
a.reviews.Register(r)
// Sibling REST controllers plug in here.
})
// Long-lived streams intentionally bypass the REST timeout middleware.

View File

@ -412,6 +412,95 @@ paths:
summary: Resolve review threads on a pull request
tags:
- prs
/api/v1/reviews:
get:
operationId: listReviews
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ListReviewsResponse'
description: OK
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: List code-review runs
tags:
- reviews
/api/v1/reviews/{id}/send:
post:
operationId: sendReview
parameters:
- description: Review run id.
in: path
name: id
required: true
schema:
description: Review run id.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewResponse'
description: OK
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Send a review run's findings to its PR
tags:
- reviews
/api/v1/reviews/execute:
post:
operationId: executeReview
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReviewInput'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewResponse'
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Bad Request
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Unprocessable Entity
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Start a code-review run for a session's PR
tags:
- reviews
/api/v1/sessions:
get:
operationId: listSessions
@ -1077,6 +1166,14 @@ components:
- state
- lastActivityAt
type: object
ExecuteReviewInput:
properties:
sessionId:
description: Session whose PR to review.
type: string
required:
- sessionId
type: object
KillSessionResponse:
properties:
freed:
@ -1098,6 +1195,15 @@ components:
required:
- projects
type: object
ListReviewsResponse:
properties:
reviews:
items:
$ref: '#/components/schemas/ReviewRun'
type: array
required:
- reviews
type: object
ListSessionPRsResponse:
properties:
prs:
@ -1299,6 +1405,54 @@ components:
- sessionId
- session
type: object
ReviewFinding:
properties:
body:
type: string
id:
type: string
line:
type: integer
path:
type: string
severity:
type: string
required:
- id
- path
- line
- severity
- body
type: object
ReviewResponse:
properties:
review:
$ref: '#/components/schemas/ReviewRun'
required:
- review
type: object
ReviewRun:
properties:
createdAt:
format: date-time
type: string
findings:
items:
$ref: '#/components/schemas/ReviewFinding'
type: array
id:
type: string
sessionId:
type: string
status:
type: string
required:
- id
- sessionId
- status
- createdAt
- findings
type: object
RoleOverride:
properties:
agent:
@ -1535,5 +1689,7 @@ tags:
name: sessions
- description: Pull-request actions (SCM lane)
name: prs
- description: Code-review runs and findings
name: reviews
- description: Server-sent CDC event stream with durable replay
name: events

View File

@ -61,6 +61,8 @@ func Build() ([]byte, error) {
"Agent session lifecycle and messaging"),
*(&openapi31.Tag{Name: "prs"}).WithDescription(
"Pull-request actions (SCM lane)"),
*(&openapi31.Tag{Name: "reviews"}).WithDescription(
"Code-review runs and findings"),
*(&openapi31.Tag{Name: "events"}).WithDescription(
"Server-sent CDC event stream with durable replay"),
}
@ -157,6 +159,13 @@ var schemaNames = map[string]string{
"ControllersMergePRResponse": "MergePRResponse",
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
"ControllersResolveCommentsResponse": "ResolveCommentsResponse",
// httpd/controllers — review wire envelopes
"ControllersListReviewsResponse": "ListReviewsResponse",
"ControllersExecuteReviewInput": "ExecuteReviewInput",
"ControllersReviewResponse": "ReviewResponse",
// service/review entities
"ReviewRun": "ReviewRun",
"ReviewFinding": "ReviewFinding",
// service/project entities + DTOs
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
@ -242,9 +251,46 @@ func operations() []operation {
ops = append(ops, projectOperations()...)
ops = append(ops, sessionOperations()...)
ops = append(ops, prOperations()...)
ops = append(ops, reviewOperations()...)
return ops
}
// reviewOperations declares the /reviews operations. Must stay 1:1 with the
// routes ReviewsController.Register mounts (enforced by the parity test).
func reviewOperations() []operation {
return []operation{
{
method: http.MethodGet, path: "/api/v1/reviews", id: "listReviews", tag: "reviews",
summary: "List code-review runs",
resps: []respUnit{
{http.StatusOK, controllers.ListReviewsResponse{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/reviews/execute", id: "executeReview", tag: "reviews",
summary: "Start a code-review run for a session's PR",
reqBody: controllers.ExecuteReviewInput{},
resps: []respUnit{
{http.StatusCreated, controllers.ReviewResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusUnprocessableEntity, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/reviews/{id}/send", id: "sendReview", tag: "reviews",
summary: "Send a review run's findings to its PR",
pathParams: []any{controllers.ReviewIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.ReviewResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
}
}
type eventsQuery struct {
After *int64 `query:"after,omitempty" minimum:"0" description:"Replay events with seq greater than this cursor. When omitted, clients may send Last-Event-ID instead."`
}

View File

@ -0,0 +1,103 @@
package controllers
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review"
)
// ReviewIDParam is the {id} path parameter on the /reviews/{id} routes.
type ReviewIDParam struct {
ID string `path:"id" description:"Review run id."`
}
// ListReviewsResponse is the body of GET /api/v1/reviews.
type ListReviewsResponse struct {
Reviews []reviewsvc.Run `json:"reviews"`
}
// ExecuteReviewInput is the body of POST /api/v1/reviews/execute.
type ExecuteReviewInput struct {
SessionID string `json:"sessionId" description:"Session whose PR to review."`
}
// ReviewResponse is the { review } body of execute (201) and send (200).
type ReviewResponse struct {
Review reviewsvc.Run `json:"review"`
}
// ReviewsController owns the /reviews routes. A nil Svc returns 501.
type ReviewsController struct {
Svc reviewsvc.Manager
}
// Register mounts the review routes on the supplied router.
func (c *ReviewsController) Register(r chi.Router) {
r.Get("/reviews", c.list)
r.Post("/reviews/execute", c.execute)
r.Post("/reviews/{id}/send", c.send)
}
func (c *ReviewsController) list(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/reviews")
return
}
runs, err := c.Svc.List(r.Context())
if err != nil {
writeReviewError(w, r, err)
return
}
if runs == nil {
runs = []reviewsvc.Run{}
}
envelope.WriteJSON(w, http.StatusOK, ListReviewsResponse{Reviews: runs})
}
func (c *ReviewsController) execute(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/reviews/execute")
return
}
var in ExecuteReviewInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil)
return
}
run, err := c.Svc.Execute(r.Context(), in.SessionID)
if err != nil {
writeReviewError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusCreated, ReviewResponse{Review: run})
}
func (c *ReviewsController) send(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/reviews/{id}/send")
return
}
run, err := c.Svc.Send(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeReviewError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, ReviewResponse{Review: run})
}
func writeReviewError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, reviewsvc.ErrInvalid):
envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "REVIEW_INVALID", err.Error(), nil)
case errors.Is(err, reviewsvc.ErrNotFound):
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "REVIEW_NOT_FOUND", "Unknown review run", nil)
default:
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "REVIEW_OPERATION_FAILED", "Review operation failed", nil)
}
}

View File

@ -0,0 +1,108 @@
// Package review is the daemon's code-review surface: review runs against a
// session's PR and the findings they produce.
//
// This is an in-memory implementation. Execution is not yet wired to a real
// review agent — Execute records a pending run so the HTTP surface is live and
// the dashboard renders against real endpoints; agent-backed findings and
// persistence are a follow-up. Mirrors agent-orchestrator's reviews feature
// (packages/web/src/app/api/reviews) on reverbcode's daemon.
package review
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// ErrInvalid and ErrNotFound let the HTTP layer map service failures to 422/404.
var (
ErrInvalid = errors.New("review: invalid input")
ErrNotFound = errors.New("review: not found")
)
// Severity ranks a finding by how much it should block the human; one of
// "info" | "warning" | "error". Kept as a plain string on the wire.
const (
SeverityInfo = "info"
SeverityWarning = "warning"
SeverityError = "error"
)
// Finding is one review comment produced for a run.
type Finding struct {
ID string `json:"id"`
Path string `json:"path"`
Line int `json:"line"`
Severity string `json:"severity"`
Body string `json:"body"`
}
// Run is one code-review execution against a session's PR.
type Run struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
// Status is one of: pending | complete | sent.
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
Findings []Finding `json:"findings"`
}
// Manager is the reviews surface the HTTP controller depends on.
type Manager interface {
List(ctx context.Context) ([]Run, error)
Execute(ctx context.Context, sessionID string) (Run, error)
Send(ctx context.Context, id string) (Run, error)
}
type memStore struct {
mu sync.Mutex
runs map[string]*Run
seq int
}
// NewInMemory returns an in-memory Manager. Runs do not survive a daemon
// restart.
func NewInMemory() Manager {
return &memStore{runs: map[string]*Run{}}
}
func (s *memStore) List(_ context.Context) ([]Run, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Run, 0, len(s.runs))
for _, run := range s.runs {
out = append(out, *run)
}
return out, nil
}
func (s *memStore) Execute(_ context.Context, sessionID string) (Run, error) {
if sessionID == "" {
return Run{}, fmt.Errorf("%w: sessionId is required", ErrInvalid)
}
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
run := &Run{
ID: fmt.Sprintf("rev-%d", s.seq),
SessionID: sessionID,
Status: "pending",
CreatedAt: time.Now().UTC(),
Findings: []Finding{},
}
s.runs[run.ID] = run
return *run, nil
}
func (s *memStore) Send(_ context.Context, id string) (Run, error) {
s.mu.Lock()
defer s.mu.Unlock()
run, ok := s.runs[id]
if !ok {
return Run{}, fmt.Errorf("%w: review %q", ErrNotFound, id)
}
run.Status = "sent"
return *run, nil
}

2
frontend/.npmrc Normal file
View File

@ -0,0 +1,2 @@
node-linker=hoisted
minimum-release-age=0

21
frontend/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/renderer/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@ -14,7 +14,6 @@ const config: ForgeConfig = {
osxSign: process.env.CSC_LINK ? {} : undefined,
osxNotarize: process.env.APPLE_ID
? {
tool: "notarytool",
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD!,
teamId: process.env.APPLE_TEAM_ID!,
@ -24,7 +23,7 @@ const config: ForgeConfig = {
rebuildConfig: {},
makers: [
{ name: "@electron-forge/maker-squirrel", config: { name: "AgentOrchestrator" } },
{ name: "@electron-forge/maker-zip", platforms: ["darwin"] },
{ name: "@electron-forge/maker-zip", platforms: ["darwin"], config: {} },
{ name: "@electron-forge/maker-deb", config: {} },
{ name: "@electron-forge/maker-rpm", config: {} },
],

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@
"make": "electron-forge make",
"publish": "electron-forge publish",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test": "vitest run --config vite.renderer.config.ts",
"test:e2e": "playwright test",
"api:ts": "openapi-typescript ../backend/internal/httpd/apispec/openapi.yaml -o src/api/schema.ts"
},
@ -56,6 +56,7 @@
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
@ -63,6 +64,7 @@
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"openapi-fetch": "^0.17.0",
"radix-ui": "^1.5.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-resizable-panels": "^4.11.2",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
# pnpm settings (pnpm ≥10 reads these from pnpm-workspace.yaml, not package.json).
# Flat npm-style node_modules: electron-forge's Vite dep-optimizer emits chunks
# that re-import sub-deps (@tanstack/query-core, @radix-ui/primitive, …) by bare
# specifier, which only resolve in a hoisted layout. .npmrc's node-linker=hoisted
# is ignored by pnpm ≥10, so it must live here.
nodeLinker: hoisted
# Electron's postinstall downloads the runtime binary into node_modules/electron/dist;
# without this approval pnpm skips it and `electron-forge start` cannot launch.
allowBuilds:
electron: true
electron-winstaller: true
# @electron/rebuild (via electron-forge) depends on @electron/node-gyp as a git
# URL; pnpm 11's blockExoticSubdeps default rejects it on every re-resolution,
# which would make any `pnpm add` fail in this project.
blockExoticSubdeps: false
# pnpm 11 defaults to a 24h supply-chain quarantine for fresh releases, which
# kept rejecting routine transitive bumps (electron-to-chromium, semver, …) and
# blocked every install. .npmrc's minimum-release-age=0 is ignored by pnpm ≥10,
# so the opt-out has to live here.
minimumReleaseAge: 0

View File

@ -143,6 +143,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List code-review runs */
get: operations["listReviews"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reviews/{id}/send": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Send a review run's findings to its PR */
post: operations["sendReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reviews/execute": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Start a code-review run for a session's PR */
post: operations["executeReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/sessions": {
parameters: {
query?: never;
@ -371,6 +422,10 @@ export interface components {
lastActivityAt: string;
state: string;
};
ExecuteReviewInput: {
/** @description Session whose PR to review. */
sessionId: string;
};
KillSessionResponse: {
freed?: boolean;
ok: boolean;
@ -379,6 +434,9 @@ export interface components {
ListProjectsResponse: {
projects: components["schemas"]["ProjectSummary"][];
};
ListReviewsResponse: {
reviews: components["schemas"]["ReviewRun"][];
};
ListSessionPRsResponse: {
prs: components["schemas"]["SessionPRFacts"][];
sessionId: string;
@ -457,6 +515,24 @@ export interface components {
session: components["schemas"]["Session"];
sessionId: string;
};
ReviewFinding: {
body: string;
id: string;
line: number;
path: string;
severity: string;
};
ReviewResponse: {
review: components["schemas"]["ReviewRun"];
};
ReviewRun: {
/** Format: date-time */
createdAt: string;
findings: components["schemas"]["ReviewFinding"][];
id: string;
sessionId: string;
status: string;
};
RoleOverride: {
agent?: string;
agentConfig?: components["schemas"]["AgentConfig"];
@ -1084,6 +1160,127 @@ export interface operations {
};
};
};
listReviews: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ListReviewsResponse"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
sendReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Review run id. */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
executeReview: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExecuteReviewInput"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
listSessions: {
parameters: {
query?: {

View File

@ -1,5 +1,5 @@
import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell, type OpenDialogOptions } from "electron";
import updateElectronApp from "update-electron-app";
import { updateElectronApp } from "update-electron-app";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { readFile } from "node:fs/promises";
import os from "node:os";

View File

@ -1,263 +0,0 @@
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { TooltipProvider } from "./components/ui/tooltip";
import { useUiStore } from "./stores/ui-store";
const { postMock, patchMock, mockData } = vi.hoisted(() => ({
postMock: vi.fn(),
patchMock: vi.fn(),
mockData: {
projectsError: undefined as Error | undefined,
projects: [] as { id: string; name: string; path: string; sessionPrefix: string }[],
sessions: [] as {
id: string;
projectId: string;
displayName?: string;
harness?: string;
status: string;
isTerminated: boolean;
updatedAt: string;
}[],
},
}));
vi.mock("./lib/api-client", () => ({
getApiBaseUrl: () => "http://127.0.0.1:3001",
setApiBaseUrl: () => undefined,
subscribeApiBaseUrl: () => () => undefined,
apiClient: {
GET: vi.fn(async (url: string) => {
if (url === "/api/v1/projects") {
if (mockData.projectsError) return { data: undefined, error: mockData.projectsError };
return { data: { projects: mockData.projects }, error: undefined };
}
if (url === "/api/v1/sessions") {
return { data: { sessions: mockData.sessions }, error: undefined };
}
return { data: undefined, error: new Error(`unexpected GET ${url}`) };
}),
POST: postMock,
PATCH: patchMock,
},
}));
vi.mock("./components/TerminalPane", () => ({
TerminalPane: () => <div>Terminal scaffold</div>,
}));
function renderApp() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// TooltipProvider mirrors routes/__root.tsx, which wraps App in production.
return render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<App />
</TooltipProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
postMock.mockReset();
patchMock.mockReset();
mockData.projectsError = undefined;
mockData.projects = [];
mockData.sessions = [];
window.localStorage.clear();
useUiStore.setState({
view: "orchestrator",
workbenchTab: "changes",
isSidebarOpen: true,
selectedSessionId: null,
selectedWorkspaceId: null,
theme: "dark",
});
});
test("renders the orchestrator anchor and empty state", async () => {
renderApp();
expect(await screen.findByRole("button", { name: "Orchestrator" })).toBeInTheDocument();
expect(await screen.findByText("No projects yet.")).toBeInTheDocument();
});
test("surfaces project load failures instead of the empty state", async () => {
mockData.projectsError = new TypeError("Failed to fetch");
renderApp();
expect(await screen.findByText("Could not load projects.", undefined, { timeout: 3_000 })).toBeInTheDocument();
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
expect(screen.queryByText("No projects yet.")).not.toBeInTheDocument();
});
test("renders projects and sessions from the API", async () => {
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
mockData.sessions = [
{
id: "sess-1",
projectId: "proj-1",
displayName: "fix-bug",
harness: "claude-code",
status: "working",
isTerminated: false,
updatedAt: new Date().toISOString(),
},
];
renderApp();
expect(await screen.findByRole("button", { name: "Select my-app" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "fix-bug" })).toBeInTheDocument();
});
test("adds a project from the rail", async () => {
const user = userEvent.setup();
const bridge = window.ao;
if (!bridge) throw new Error("test preload bridge is not installed");
bridge.app.chooseDirectory = vi.fn(async () => "/Users/me/new-project");
postMock.mockResolvedValueOnce({
data: {
project: {
id: "new-project",
name: "New Project",
path: "/Users/me/new-project",
repo: "git@example.com:new-project.git",
defaultBranch: "main",
},
},
});
renderApp();
await user.click(await screen.findByRole("button", { name: "New project" }));
expect(bridge.app.chooseDirectory).toHaveBeenCalled();
expect(postMock).toHaveBeenCalledWith("/api/v1/projects", { body: { path: "/Users/me/new-project" } });
expect(await screen.findByText("New Project")).toBeInTheDocument();
});
test("spawns a worker from the New worker modal", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
postMock.mockResolvedValueOnce({
data: {
session: {
id: "new-task",
projectId: "proj-1",
harness: "claude-code",
branch: "main",
isTerminated: false,
},
},
});
renderApp();
// Wait for projects to load.
await screen.findByRole("button", { name: "Select my-app" });
// Open spawn modal from the orchestrator topbar.
await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Prompt"), "Make task creation work");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
// No `branch` field: it names the worktree branch, and sending the default
// base ("main") makes the daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE.
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "claude-code",
prompt: "Make task creation work",
},
});
// No worker name given, so no rename round-trip.
expect(patchMock).not.toHaveBeenCalled();
});
test("renames the spawned worker when a name is given", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
postMock.mockResolvedValueOnce({
data: {
session: {
id: "new-task",
projectId: "proj-1",
harness: "claude-code",
isTerminated: false,
},
},
});
patchMock.mockResolvedValueOnce({
data: { ok: true, sessionId: "new-task", displayName: "fix-login" },
});
renderApp();
await screen.findByRole("button", { name: "Select my-app" });
await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Worker name"), "fix-login");
await user.type(screen.getByLabelText("Prompt"), "Fix the login bug");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
expect(patchMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}", {
params: { path: { sessionId: "new-task" } },
body: { displayName: "fix-login" },
});
expect(await screen.findByRole("button", { name: "fix-login" })).toBeInTheDocument();
});
test("surfaces an error when spawning fails", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
postMock.mockResolvedValueOnce({ error: new TypeError("Failed to fetch") });
renderApp();
await screen.findByRole("button", { name: "Select my-app" });
await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Prompt"), "Failing task");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "claude-code",
prompt: "Failing task",
},
});
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
});
test("surfaces the daemon error envelope message, not [object Object]", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
// openapi-fetch resolves non-2xx bodies as a plain APIError envelope.
postMock.mockResolvedValueOnce({
error: {
code: "BRANCH_CHECKED_OUT_ELSEWHERE",
error: "Conflict",
message: "main is checked out at /home/me/my-app",
},
});
renderApp();
await screen.findByRole("button", { name: "Select my-app" });
await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Prompt"), "Failing task");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
expect(
await screen.findByText("main is checked out at /home/me/my-app (BRANCH_CHECKED_OUT_ELSEWHERE)"),
).toBeInTheDocument();
expect(screen.queryByText("[object Object]")).not.toBeInTheDocument();
});

View File

@ -1,241 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { CenterPane } from "./components/CenterPane";
import { SideRail } from "./components/SideRail";
import { Sidebar } from "./components/Sidebar";
import { SpawnWorkerModal } from "./components/SpawnWorkerModal";
import { Topbar } from "./components/Topbar";
import { useDaemonStatus } from "./hooks/useDaemonStatus";
import { useWorkspaceQuery, workspaceQueryKey } from "./hooks/useWorkspaceQuery";
import { apiClient } from "./lib/api-client";
import { Theme, useUiStore } from "./stores/ui-store";
import { toAgentProvider, toSessionStatus, type AgentProvider, type WorkspaceSummary } from "./types/workspace";
type AppProps = {
routeSessionId?: string;
routeWorkspaceId?: string;
};
function systemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Could not load projects";
}
/**
* openapi-fetch resolves non-2xx responses to a plain APIError envelope
* ({ code, error, message, ... }), not an Error String() on it renders
* "[object Object]".
*/
function apiErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) return error.message;
if (typeof error === "string" && error) return error;
if (error && typeof error === "object") {
const envelope = error as { message?: unknown; code?: unknown };
if (typeof envelope.message === "string" && envelope.message) {
return typeof envelope.code === "string" && envelope.code
? `${envelope.message} (${envelope.code})`
: envelope.message;
}
}
return fallback;
}
export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
const queryClient = useQueryClient();
const {
view,
workbenchTab,
selectedSessionId,
selectedWorkspaceId,
theme,
setSystemTheme,
setWorkbenchTab,
toggleSidebar,
selectWorkspace,
selectSession,
} = useUiStore();
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const daemonStatus = useDaemonStatus(queryClient);
const [spawnOpen, setSpawnOpen] = useState(false);
const [spawnProjectId, setSpawnProjectId] = useState<string | undefined>(undefined);
const openSpawn = (projectId?: string) => {
setSpawnProjectId(projectId);
setSpawnOpen(true);
};
const selectedWorkspace =
workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? workspaces[0] ?? null;
const selectedSession =
view === "session"
? workspaces.flatMap((workspace) => workspace.sessions).find((session) => session.id === selectedSessionId)
: undefined;
const sessionWorkspace = selectedSession
? (workspaces.find((workspace) => workspace.id === selectedSession.workspaceId) ?? selectedWorkspace)
: selectedWorkspace;
useEffect(() => {
if (routeWorkspaceId) selectWorkspace(routeWorkspaceId);
if (routeSessionId) selectSession(routeSessionId, routeWorkspaceId);
}, [routeSessionId, routeWorkspaceId, selectWorkspace, selectSession]);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}, [theme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: light)");
const handleChange = () => setSystemTheme(systemTheme());
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [setSystemTheme]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "b") {
event.preventDefault();
toggleSidebar();
return;
}
if ((event.metaKey || event.ctrlKey) && /^[1-9]$/.test(event.key)) {
const workspace = workspaces[Number(event.key) - 1];
if (workspace) {
event.preventDefault();
selectWorkspace(workspace.id);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectWorkspace, toggleSidebar, workspaces]);
const updateWorkspaces = (updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
queryClient.setQueryData<WorkspaceSummary[]>(workspaceQueryKey, (current = workspaces) => updater(current));
};
const createProject = async (input: { path: string }) => {
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
if (error) throw new Error(apiErrorMessage(error, "Could not add project"));
if (!data?.project) throw new Error("Project creation returned no project");
const workspace: WorkspaceSummary = {
id: data.project.id,
name: data.project.name,
path: data.project.path,
type: "main",
sessions: [],
};
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
selectWorkspace(workspace.id);
};
const createTask = async (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => {
// No `branch` here: the API's branch field names the session's worktree
// branch (sending a checked-out branch like "main" 409s); the base branch
// comes from the project config.
const { data, error } = await apiClient.POST("/api/v1/sessions", {
body: {
projectId: input.projectId,
kind: "worker",
harness: input.harness,
prompt: input.prompt,
},
});
if (error || !data?.session) {
throw new Error(apiErrorMessage(error, "No session returned"));
}
const session = data.session;
// Best-effort: the session is already running, so a failed rename should
// not surface as a failed spawn (retrying would create a duplicate).
let displayName: string | undefined;
if (input.name) {
const renamed = await apiClient.PATCH("/api/v1/sessions/{sessionId}", {
params: { path: { sessionId: session.id } },
body: { displayName: input.name },
});
displayName = renamed.data?.displayName;
}
updateWorkspaces((current) =>
current.map((item) =>
item.id === input.projectId
? {
...item,
sessions: [
{
id: session.id,
terminalHandleId: session.terminalHandleId,
workspaceId: item.id,
workspaceName: item.name,
title: displayName ?? input.prompt,
provider: toAgentProvider(session.harness),
branch: "",
status: toSessionStatus(session.status, session.isTerminated),
updatedAt: "now",
},
...item.sessions.filter((existing) => existing.id !== session.id),
],
}
: item,
),
);
selectSession(session.id, input.projectId);
};
const showSideRail = !(view === "session" && workbenchTab === "terminal");
return (
<>
<div className="flex h-screen flex-col bg-background text-foreground">
<Topbar
onNewWorker={() => openSpawn()}
onSetWorkbenchTab={setWorkbenchTab}
onToggleSidebar={toggleSidebar}
session={selectedSession}
view={view}
workbenchTab={workbenchTab}
workspace={sessionWorkspace}
/>
<div className="flex min-h-0 flex-1">
<Sidebar
daemonStatus={daemonStatus}
onCreateProject={createProject}
onNewWorker={openSpawn}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
workspaces={workspaces}
/>
<CenterPane
daemonReady={daemonStatus.state === "ready"}
session={selectedSession}
theme={theme}
view={view}
/>
{showSideRail && (
<SideRail onSelectSession={selectSession} session={selectedSession} view={view} workspaces={workspaces} />
)}
</div>
</div>
<SpawnWorkerModal
defaultProjectId={spawnProjectId ?? selectedWorkspace?.id}
onCreateTask={createTask}
onOpenChange={setSpawnOpen}
open={spawnOpen}
workspaces={workspaces}
/>
</>
);
}

View File

@ -1,45 +1,16 @@
import { Columns2 } from "lucide-react";
import type { Theme, WorkbenchView } from "../stores/ui-store";
import type { Theme } from "../stores/ui-store";
import type { WorkspaceSession } from "../types/workspace";
import { TerminalPane } from "./TerminalPane";
type CenterPaneProps = {
view: WorkbenchView;
session?: WorkspaceSession;
theme: Theme;
daemonReady: boolean;
};
export function CenterPane({ view, session, theme, daemonReady }: CenterPaneProps) {
const isOrchestrator = view === "orchestrator";
const agentLabel = session?.provider ?? "claude-code";
export function CenterPane({ session, theme, daemonReady }: CenterPaneProps) {
return (
<div className="flex min-w-0 flex-1 flex-col bg-background">
<div className="flex h-[38px] shrink-0 items-center border-b border-border px-2.5">
<div className="-mb-px flex h-[38px] items-center gap-2 border-b-2 border-accent px-3 text-[13px] text-foreground">
<span className="h-[7px] w-[7px] rounded-full bg-success shadow-[0_0_0_3px_rgb(108_177_108_/_0.24)]" />
{isOrchestrator ? (
<>
orchestrator <span className="font-mono text-[11px] text-passive">{agentLabel}</span>
</>
) : (
<>
{agentLabel} <span className="font-mono text-[11px] text-passive">(1)</span>
</>
)}
</div>
{!isOrchestrator && (
<button
aria-label="Split terminal"
className="ml-auto grid h-7 w-7 place-items-center rounded-md text-passive transition-colors hover:bg-raised hover:text-muted-foreground"
type="button"
>
<Columns2 className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
)}
</div>
<div className="flex h-full min-h-0 min-w-0 flex-col bg-background">
<div className="min-h-0 flex-1">
<TerminalPane session={session} theme={theme} daemonReady={daemonReady} />
</div>

View File

@ -0,0 +1,142 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Bell, Waypoints } from "lucide-react";
import { useState } from "react";
import { findProjectOrchestrator } from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useUiStore } from "../stores/ui-store";
import { cn } from "../lib/utils";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type DashboardTab = "coding" | "reviews";
type DashboardTopbarProps = {
/** Which top-nav tab reads as active (omit on the PR board, which is neither). */
activeTab?: DashboardTab;
/** When set, the project crumb scopes to one project. */
projectId?: string;
projectLabel?: string;
};
// The dashboard header (mc-board .dashboard-app-header): project crumb · Coding/
// Reviews tabs | bell · Orchestrator (board ↔ terminal).
// Shared verbatim across the board, review, and PR screens so navigating between
// them keeps one stable top strip (agent-orchestrator surfaces them as tabs).
export function DashboardTopbar({ activeTab, projectId, projectLabel = "agent-orchestrator" }: DashboardTopbarProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isSpawning, setIsSpawning] = useState(false);
const isSidebarOpen = useUiStore((state) => state.isSidebarOpen);
const all = useWorkspaceQuery().data ?? [];
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
const openOrchestrator = async () => {
if (!projectId) return;
if (orchestrator) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} catch (error) {
console.error("Failed to spawn orchestrator:", error);
} finally {
setIsSpawning(false);
}
};
return (
<header
className={cn("dashboard-app-header", isMac && !isSidebarOpen && "is-under-titlebar-nav")}
style={dragStyle}
>
<div className="session-topbar__lead">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{projectLabel}</span>
<nav aria-label="Workspace mode" className="dashboard-app-header__tabs">
<button
className={cn("dashboard-app-header__tab", activeTab === "coding" && "is-active")}
onClick={() =>
void navigate(projectId ? { to: "/projects/$projectId", params: { projectId } } : { to: "/" })
}
style={noDragStyle}
type="button"
>
Coding
</button>
<button
className={cn("dashboard-app-header__tab", activeTab === "reviews" && "is-active")}
onClick={() => void navigate({ to: "/review" })}
style={noDragStyle}
type="button"
>
Reviews
</button>
</nav>
</div>
</div>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<button aria-label="Notifications" className="dashboard-app-header__icon-btn" style={noDragStyle} type="button">
<Bell className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
{projectId ? (
orchestrator ? (
<button
aria-label="Orchestrator"
className="dashboard-app-header__primary-btn"
onClick={() =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
})
}
style={noDragStyle}
type="button"
>
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
Orchestrator
</button>
) : (
<button
aria-label="Spawn Orchestrator"
className="dashboard-app-header__primary-btn"
disabled={isSpawning}
onClick={() => void openOrchestrator()}
style={noDragStyle}
type="button"
>
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning…" : "Spawn Orchestrator"}
</button>
)
) : null}
</div>
</header>
);
}
// The board subhead (mc-board .dashboard-main__subhead): a 21px bold title with
// a muted one-line subtitle, optionally a trailing count.
export function DashboardSubhead({ title, subtitle, count }: { title: string; subtitle: string; count?: number }) {
return (
<div className="flex items-baseline gap-3 px-[18px] pt-[22px]">
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
{typeof count === "number" && <span className="font-mono text-[13px] text-passive">{count}</span>}
<span className="text-[12.5px] text-passive">{subtitle}</span>
</div>
);
}

View File

@ -0,0 +1,237 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
type Project = components["schemas"]["Project"];
type ProjectConfig = components["schemas"]["ProjectConfig"];
// Agents the daemon registers (see SpawnWorkerModal). Empty = "use the daemon
// default". Kept short — the spawn modal owns the full list.
const AGENT_OPTIONS = ["claude-code", "codex", "opencode", "amp", "goose", "kiro"] as const;
const projectQueryKey = (id: string) => ["project", id] as const;
export function ProjectSettingsForm({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/v1/projects/{id}", {
params: { path: { id: projectId } },
});
if (error) throw new Error(apiErrorMessage(error));
if (data?.status !== "ok") throw new Error("Project config is unavailable (degraded).");
return data.project as Project;
},
});
if (query.isLoading) {
return <CenteredNote>Loading project settings</CenteredNote>;
}
if (query.isError || !query.data) {
return (
<CenteredNote>{query.error instanceof Error ? query.error.message : "Could not load project."}</CenteredNote>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar activeTab="coding" projectId={projectId} projectLabel={query.data.name} />
<DashboardSubhead title="Settings" subtitle={query.data.path} />
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
<SettingsBody
key={projectId}
project={query.data}
onSaved={() => queryClient.invalidateQueries({ queryKey: workspaceQueryKey })}
projectId={projectId}
/>
</div>
</div>
);
}
function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) {
const queryClient = useQueryClient();
const config = project.config ?? {};
const [form, setForm] = useState({
defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "",
sessionPrefix: config.sessionPrefix ?? "",
workerAgent: config.worker?.agent ?? "",
orchestratorAgent: config.orchestrator?.agent ?? "",
model: config.agentConfig?.model ?? "",
});
const [savedAt, setSavedAt] = useState<number | null>(null);
const mutation = useMutation({
mutationFn: async () => {
// PUT replaces the whole config; merge the edited fields over what loaded
// so we don't drop env/symlinks/postCreate the form doesn't expose.
const next: ProjectConfig = {
...config,
defaultBranch: form.defaultBranch || undefined,
sessionPrefix: form.sessionPrefix || undefined,
worker: blankToUndefined({ ...config.worker, agent: form.workerAgent || undefined }),
orchestrator: blankToUndefined({ ...config.orchestrator, agent: form.orchestratorAgent || undefined }),
agentConfig: blankToUndefined({ ...config.agentConfig, model: form.model || undefined }),
};
const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", {
params: { path: { id: projectId } },
body: { config: next },
});
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => {
setSavedAt(Date.now());
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved();
},
});
return (
<form
className="mx-auto flex max-w-2xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Identity</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2 font-mono text-[12px] text-muted-foreground">
<ReadonlyRow label="id" value={project.id} />
<ReadonlyRow label="path" value={project.path} />
<ReadonlyRow label="repo" value={project.repo || "—"} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Worktrees</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field label="Default branch" htmlFor="defaultBranch">
<input
id="defaultBranch"
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
value={form.defaultBranch}
onChange={(e) => setForm((f) => ({ ...f, defaultBranch: e.target.value }))}
placeholder="main"
/>
</Field>
<Field label="Session branch prefix" htmlFor="sessionPrefix">
<input
id="sessionPrefix"
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
value={form.sessionPrefix}
onChange={(e) => setForm((f) => ({ ...f, sessionPrefix: e.target.value }))}
placeholder="ao/"
/>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Agents</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field label="Default worker agent">
<AgentSelect value={form.workerAgent} onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))} />
</Field>
<Field label="Default orchestrator agent">
<AgentSelect
value={form.orchestratorAgent}
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
/>
</Field>
<Field label="Model override" htmlFor="model">
<input
id="model"
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
value={form.model}
onChange={(e) => setForm((f) => ({ ...f, model: e.target.value }))}
placeholder="(agent default)"
/>
</Field>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save changes"}
</Button>
{mutation.isError && (
<span className="text-[12px] text-error">
{mutation.error instanceof Error ? mutation.error.message : "Save failed"}
</span>
)}
{savedAt && !mutation.isPending && !mutation.isError && (
<span className="text-[12px] text-success">Saved.</span>
)}
</div>
</form>
);
}
function AgentSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
// "" sentinel → daemon default; Select can't hold an empty value, so map it.
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Daemon default</SelectItem>
{AGENT_OPTIONS.map((agent) => (
<SelectItem key={agent} value={agent}>
{agent}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor} className="text-[12px] text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
function ReadonlyRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-3">
<span className="w-12 shrink-0 text-passive">{label}</span>
<span className="min-w-0 flex-1 truncate text-foreground">{value}</span>
</div>
);
}
function CenteredNote({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-full place-items-center bg-background p-6 text-center text-[12px] text-passive">
{children}
</div>
);
}
// Drop an object whose every value is undefined so we send `undefined` (omit)
// rather than an empty {} the daemon would persist.
function blankToUndefined<T extends object>(obj: T): T | undefined {
return Object.values(obj).some((v) => v !== undefined) ? obj : undefined;
}

View File

@ -0,0 +1,170 @@
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import type { WorkspaceSession } from "../types/workspace";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
import { cn } from "../lib/utils";
type PRState = NonNullable<WorkspaceSession["pullRequest"]>["state"];
const stateTone: Record<PRState, string> = {
open: "border-success/40 bg-success/10 text-success",
draft: "border-border bg-raised text-muted-foreground",
merged: "border-accent/40 bg-accent-weak text-accent",
closed: "border-error/40 bg-error/10 text-error",
};
// Order open PRs (actionable) above merged/closed.
const stateRank: Record<PRState, number> = { open: 0, draft: 1, merged: 2, closed: 3 };
type PRRow = {
number: number;
state: PRState;
session: WorkspaceSession;
};
// The PR board, ported from agent-orchestrator's PullRequestsPage. The Go
// daemon has no PR-list endpoint, so the board is derived from session PR
// fields (every session carries pullRequest); actions hit /prs/{number}/merge
// and /resolve-comments. Per-PR CI/review facts live on the session route's
// inspector.
export function PullRequestsPage() {
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery();
const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions);
const rows: PRRow[] = sessions
.filter((s): s is WorkspaceSession & { pullRequest: NonNullable<WorkspaceSession["pullRequest"]> } =>
Boolean(s.pullRequest),
)
.map((s) => ({ number: s.pullRequest.number, state: s.pullRequest.state, session: s }))
.sort((a, b) => stateRank[a.state] - stateRank[b.state] || a.number - b.number);
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar />
<DashboardSubhead
title="Pull requests"
subtitle="Open PRs across every agent session, ready to resolve and merge."
count={rows.length}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
{rows.length === 0 ? (
<p className="py-10 text-center text-[12px] text-passive">No open pull requests.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">PR</TableHead>
<TableHead>Worker</TableHead>
<TableHead className="w-24">State</TableHead>
<TableHead className="w-[220px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<PRRowView
key={`${row.session.id}-${row.number}`}
row={row}
onOpen={() =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId: row.session.workspaceId, sessionId: row.session.id },
})
}
/>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
const queryClient = useQueryClient();
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
const refresh = () => void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
const merge = useMutation({
mutationFn: async () => {
const { data, error } = await apiClient.POST("/api/v1/prs/{id}/merge", {
params: { path: { id: String(row.number) } },
});
if (error) throw new Error(apiErrorMessage(error));
return data;
},
onSuccess: (data) => {
setNote({ ok: true, text: `merged (${data?.method ?? "squash"})` });
refresh();
},
onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "merge failed" }),
});
const resolve = useMutation({
mutationFn: async () => {
const { error } = await apiClient.POST("/api/v1/prs/{id}/resolve-comments", {
params: { path: { id: String(row.number) } },
});
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => {
setNote({ ok: true, text: "comments resolved" });
refresh();
},
onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "resolve failed" }),
});
const actionable = row.state === "open" || row.state === "draft";
return (
<TableRow className="cursor-pointer" onClick={onOpen}>
<TableCell className="font-mono text-[12px] text-muted-foreground">#{row.number}</TableCell>
<TableCell className="max-w-0">
<div className="truncate text-[13px] text-foreground">{row.session.title}</div>
<div className="truncate font-mono text-[10px] text-passive">
{[row.session.workspaceName, row.session.branch].filter(Boolean).join(" · ")}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", stateTone[row.state])}>
{row.state}
</Badge>
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{note ? (
<span className={cn("text-[11px]", note.ok ? "text-success" : "text-error")}>{note.text}</span>
) : actionable ? (
<div className="flex items-center justify-end gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
disabled={resolve.isPending}
onClick={() => resolve.mutate()}
>
{resolve.isPending ? "…" : "Resolve"}
</Button>
<Button
size="sm"
variant="primary"
className="h-6 px-2 text-[11px]"
disabled={merge.isPending}
onClick={() => merge.mutate()}
>
{merge.isPending ? "Merging…" : "Merge"}
</Button>
</div>
) : (
<span className="text-[11px] text-passive"></span>
)}
</TableCell>
</TableRow>
);
}

View File

@ -0,0 +1,171 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { useState } from "react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { cn } from "../lib/utils";
type ReviewRun = components["schemas"]["ReviewRun"];
const reviewsKey = ["reviews"] as const;
const statusTone: Record<string, string> = {
pending: "border-warning/40 bg-warning/10 text-warning",
complete: "border-success/40 bg-success/10 text-success",
sent: "border-accent/40 bg-accent-weak text-accent",
};
// The code-review board, ported from agent-orchestrator's ReviewDashboard onto
// the daemon's reviews API (GET/POST /api/v1/reviews). Lists review runs and
// their findings; lets you start a run for a session and send its findings.
export function ReviewDashboard() {
const queryClient = useQueryClient();
const sessions = (useWorkspaceQuery().data ?? []).flatMap((w) => w.sessions);
const [target, setTarget] = useState<string>("");
const reviews = useQuery({
queryKey: reviewsKey,
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/v1/reviews");
if (error) throw new Error(apiErrorMessage(error));
return data?.reviews ?? [];
},
});
const execute = useMutation({
mutationFn: async (sessionId: string) => {
const { error } = await apiClient.POST("/api/v1/reviews/execute", { body: { sessionId } });
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => void queryClient.invalidateQueries({ queryKey: reviewsKey }),
});
const runs = (reviews.data ?? []).slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar activeTab="reviews" />
<DashboardSubhead
title="Reviews"
subtitle="Code-review runs and their findings, ready to send back."
count={runs.length}
/>
<div className="flex items-center gap-2 px-[18px] pt-3">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="h-8 w-56 text-[12px]">
<SelectValue placeholder="Select a worker…" />
</SelectTrigger>
<SelectContent>
{sessions.length === 0 ? (
<SelectItem value="__none__" disabled>
No workers
</SelectItem>
) : (
sessions.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.title}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="primary"
className="h-8 px-3 text-[12px]"
disabled={!target || target === "__none__" || execute.isPending}
onClick={() => execute.mutate(target)}
>
<Play className="h-3 w-3" aria-hidden="true" />
{execute.isPending ? "Starting…" : "Run review"}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
{reviews.isError ? (
<p className="py-10 text-center text-[12px] text-passive">Could not load reviews.</p>
) : runs.length === 0 ? (
<p className="py-10 text-center text-[12px] text-passive">
No review runs yet. Pick a worker and <span className="text-foreground">Run review</span>.
</p>
) : (
<div className="mx-auto flex max-w-2xl flex-col gap-2.5">
{runs.map((run) => (
<ReviewRunCard
key={run.id}
run={run}
sessionTitle={sessions.find((s) => s.id === run.sessionId)?.title}
/>
))}
</div>
)}
</div>
</div>
);
}
function ReviewRunCard({ run, sessionTitle }: { run: ReviewRun; sessionTitle?: string }) {
const queryClient = useQueryClient();
const send = useMutation({
mutationFn: async () => {
const { error } = await apiClient.POST("/api/v1/reviews/{id}/send", { params: { path: { id: run.id } } });
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => void queryClient.invalidateQueries({ queryKey: reviewsKey }),
});
return (
<Card className="gap-0 py-0">
<CardContent className="flex flex-col gap-2 p-3">
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-muted-foreground">{run.id}</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{sessionTitle ?? run.sessionId}</span>
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", statusTone[run.status])}>
{run.status}
</Badge>
{run.status !== "sent" && (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
disabled={send.isPending}
onClick={() => send.mutate()}
>
{send.isPending ? "Sending…" : "Send"}
</Button>
)}
</div>
{run.findings.length === 0 ? (
<p className="font-mono text-[11px] text-passive">no findings</p>
) : (
<ul className="flex flex-col gap-1">
{run.findings.map((f) => (
<li key={f.id} className="flex items-start gap-2 text-[11px]">
<span
className={cn(
"mt-0.5 font-mono",
f.severity === "error" ? "text-error" : f.severity === "warning" ? "text-warning" : "text-passive",
)}
>
{f.severity}
</span>
<span className="min-w-0 flex-1">
<span className="font-mono text-passive">
{f.path}:{f.line}
</span>{" "}
<span className="text-muted-foreground">{f.body}</span>
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,390 @@
import { useQuery } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react";
import type { components } from "../../api/schema";
import { apiClient } from "../lib/api-client";
import { formatTimeCompact } from "../lib/format-time";
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
import { workerDisplayStatus } from "../types/workspace";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
type PRFacts = components["schemas"]["SessionPRFacts"];
type InspectorView = "summary" | "changes" | "browser";
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
{
id: "summary",
label: "Summary",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<line x1="8" y1="7" x2="20" y2="7" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="17" x2="16" y2="17" />
<circle cx="4" cy="7" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="17" r="1" />
</svg>
),
},
{
id: "changes",
label: "Changes",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<path d="M12 3v6" />
<path d="M9 6h6" />
<path d="M11 18H7a2 2 0 0 1-2-2V6" />
<path d="M13 15h4" />
<path d="M19 9v7a2 2 0 0 1-2 2h-2" />
</svg>
),
},
{
id: "browser",
label: "Browser",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<line x1="3" y1="12" x2="21" y2="12" />
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
</svg>
),
},
];
const prStateTone: Record<PRFacts["state"], string> = {
open: "border-success/40 bg-success/10 text-success",
draft: "border-border bg-raised text-muted-foreground",
merged: "border-accent/40 bg-accent-weak text-accent",
closed: "border-error/40 bg-error/10 text-error",
};
/**
* Tabbed inspector rail beside the terminal cloned from agent-orchestrator
* SessionInspector (Summary · Changes · Browser).
*/
export function SessionInspector({ session }: { session?: WorkspaceSession }) {
const [view, setView] = useState<InspectorView>("summary");
if (!session) {
return (
<aside className="session-inspector" aria-label="Session inspector">
<div className="session-inspector__body">
<p className="inspector-empty">Loading session</p>
</div>
</aside>
);
}
return (
<aside className="session-inspector" aria-label="Session inspector">
<div className="session-inspector__tabs" role="tablist">
{VIEWS.map((entry) => (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={view === entry.id}
className={cn("session-inspector__tab", view === entry.id && "is-active")}
onClick={() => setView(entry.id)}
>
<span className="session-inspector__tab-icon">{entry.icon}</span>
<span className="session-inspector__tab-label">{entry.label}</span>
</button>
))}
</div>
<div className="session-inspector__body">
{view === "summary" ? <SummaryView session={session} /> : null}
{view === "changes" ? <ChangesView session={session} /> : null}
{view === "browser" ? <BrowserView /> : null}
</div>
</aside>
);
}
function Section({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
return (
<section className="inspector-section">
<div className="inspector-section__head">
<span>{title}</span>
{action ?? null}
</div>
{children}
</section>
);
}
function SummaryView({ session }: { session: WorkspaceSession }) {
const hasPr = Boolean(session.pullRequest);
const query = useQuery({
queryKey: ["session-pr", session.id],
enabled: hasPr,
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
params: { path: { sessionId: session.id } },
});
if (error) return [] as PRFacts[];
return data?.prs ?? [];
},
});
const prFacts = query.data?.[0];
const branchLabel = session.branch || `session/${session.id}`;
return (
<div role="tabpanel">
<Section
title="Pull request"
action={
prFacts?.url ? (
<a href={prFacts.url} target="_blank" rel="noopener noreferrer" className="inspector-section__link">
Open
</a>
) : undefined
}
>
{!hasPr ? (
<p className="inspector-empty">No pull request opened yet.</p>
) : query.isLoading ? (
<p className="inspector-empty">Loading pull request</p>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<GitPullRequest className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
<span className="text-[12.5px] font-medium text-foreground">
PR #{prFacts?.number ?? session.pullRequest?.number}
</span>
{prFacts ? (
<Badge
variant="outline"
className={cn("ml-auto h-5 px-1.5 text-[10px] font-medium", prStateTone[prFacts.state])}
>
{prFacts.state}
</Badge>
) : null}
</div>
{prFacts ? (
<dl className="inspector-kv">
<Row k="CI" v={prFacts.ci || "—"} mono />
<Row k="Merge" v={prFacts.mergeability || "—"} mono />
<Row k="Review" v={prFacts.review || "—"} mono />
</dl>
) : (
<p className="inspector-empty">No enriched PR facts yet.</p>
)}
</div>
)}
</Section>
<Section title="Activity">
<ActivityTimeline session={session} />
</Section>
<Section title="Overview">
<dl className="inspector-kv">
<Row k="Agent" v={session.provider} mono />
<Row k="Branch" v={branchLabel} mono />
<Row k="Started" v={formatTimeCompact(session.createdAt ?? session.updatedAt)} mono />
<Row k="Session" v={session.id} mono />
</dl>
</Section>
</div>
);
}
type TimelineTone = "now" | "good" | "warn" | "neutral";
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
const detail = activityDetail(session.status);
events.push({
tone: "now",
node: (
<>
<span className="inspector-timeline__badge">
<InspectorStatusPill session={session} />
</span>
{detail ? <span className="inspector-timeline__detail"> {detail}</span> : null}
</>
),
ts: formatTimeCompact(session.updatedAt),
});
if (session.pullRequest) {
events.push({
tone: "good",
node: (
<>
Opened <b>PR #{session.pullRequest.number}</b>
</>
),
ts: null,
});
}
events.push({
tone: "neutral",
node: <>Created worktree &amp; branch</>,
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
});
return (
<div className="inspector-timeline">
{events.map((event, index) => (
<div
key={index}
className={cn(
"inspector-timeline__ev",
event.tone === "now" && "inspector-timeline__ev--now",
event.tone === "good" && "inspector-timeline__ev--good",
event.tone === "warn" && "inspector-timeline__ev--warn",
)}
>
<span className="inspector-timeline__node" aria-hidden="true" />
<div className="inspector-timeline__et">{event.node}</div>
{event.ts ? <div className="inspector-timeline__ets">{event.ts}</div> : null}
</div>
))}
</div>
);
}
function activityDetail(status: SessionStatus): string | null {
switch (status) {
case "idle":
return "Session idle";
case "needs_input":
return "Waiting for input";
case "working":
return null;
default:
return null;
}
}
const STATUS_PILL: Record<
ReturnType<typeof workerDisplayStatus> | "idle",
{ label: string; tone: string; breathe: boolean }
> = {
working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
};
function InspectorStatusPill({ session }: { session: WorkspaceSession }) {
const key = session.status === "idle" ? "idle" : workerDisplayStatus(session);
const { label, tone, breathe } = STATUS_PILL[key];
return (
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
style={{
color: tone,
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
}}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
style={{ background: tone }}
/>
{label}
</span>
);
}
function ChangesView({ session }: { session: WorkspaceSession }) {
const files = session.changedFiles ?? [];
return (
<div role="tabpanel" className="flex min-h-0 flex-1 flex-col">
<div className="inspector-changes__head">
<span className="inspector-changes__title">Changed</span>
<span className="inspector-changes__count">{files.length}</span>
</div>
<div className="inspector-changes__actions">
<button className="inspector-changes__action" type="button">
All files
</button>
<button className="inspector-changes__action inspector-changes__action--danger" type="button">
<Trash2 aria-hidden="true" />
Discard all
</button>
<button className="inspector-changes__action inspector-changes__action--end" type="button">
<Plus aria-hidden="true" />
Stage all
</button>
</div>
<div className="inspector-changes__list">
{files.length === 0 ? (
<p className="inspector-empty inspector-empty--center">No changes yet.</p>
) : (
files.map((file) => (
<div className="inspector-changes__file" key={file.path}>
<span className="inspector-changes__path">{file.path}</span>
<span className="inspector-changes__add">+{file.additions}</span>
<span className="inspector-changes__del">{file.deletions}</span>
<Square className={cn("inspector-changes__stage", file.staged && "is-staged")} aria-hidden="true" />
</div>
))
)}
</div>
<div className="inspector-changes__commit">
<input
className="inspector-changes__input"
defaultValue={session.commitMessage ?? ""}
key={session.id}
placeholder="Commit message"
/>
<textarea className="inspector-changes__textarea" placeholder="Description" rows={2} />
<Button className="w-full" disabled={files.length === 0} variant="primary">
<GitCommitHorizontal aria-hidden="true" />
Commit &amp; Push
</Button>
</div>
<div className="inspector-changes__footer">
<GitBranch aria-hidden="true" />
<span className="inspector-changes__branch">{session.branch || "—"}</span>
<button className="inspector-changes__pr" type="button">
<Plus aria-hidden="true" />
<GitPullRequest aria-hidden="true" />
Create PR
</button>
</div>
</div>
);
}
function BrowserView() {
return (
<div role="tabpanel">
<div className="inspector-empty inspector-empty--browser">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<line x1="3" y1="12" x2="21" y2="12" />
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
</svg>
<p>No live browser preview.</p>
<span>A browser plugin will render what the agent is viewing here.</span>
</div>
</div>
);
}
function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return (
<div className="inspector-kv__row">
<dt className="inspector-kv__k">{k}</dt>
<dd className={cn("inspector-kv__v", mono && "inspector-kv__v--mono")}>{v}</dd>
</div>
);
}

View File

@ -0,0 +1,207 @@
import type { ReactNode, Ref } from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { SessionView } from "./SessionView";
import { useUiStore } from "../stores/ui-store";
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
type FakePanelHandle = {
collapse: Mock;
expand: Mock;
getSize: Mock;
isCollapsed: Mock;
resize: Mock;
};
type PanelEntry = {
handle: FakePanelHandle;
onResize?: (size: { asPercentage: number; inPixels: number }) => void;
};
const { workspaces, panels } = vi.hoisted(() => {
const worker = {
id: "sess-1",
workspaceId: "proj-1",
workspaceName: "my-app",
title: "do the thing",
provider: "claude-code",
kind: "worker",
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
} satisfies WorkspaceSession;
const orchestrator = {
...worker,
id: "sess-orch",
kind: "orchestrator",
title: "orchestrate",
} satisfies WorkspaceSession;
const workspaces: WorkspaceSummary[] = [
{ id: "proj-1", name: "my-app", path: "/p", type: "main", sessions: [worker, orchestrator] },
];
return { workspaces, panels: new Map<string, PanelEntry>() };
});
// The terminal, inspector body, and topbar pull in xterm/router/SSE machinery
// irrelevant to the split under test.
vi.mock("./CenterPane", () => ({ CenterPane: () => <div /> }));
vi.mock("./SessionInspector", () => ({ SessionInspector: () => <div /> }));
vi.mock("./Topbar", () => ({ Topbar: () => <header /> }));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("../lib/shell-context", () => ({
useShell: () => ({ daemonStatus: { state: "ready" } }),
}));
vi.mock("../hooks/useWorkspaceQuery", () => ({
useWorkspaceQuery: () => ({ data: workspaces, isLoading: false }),
}));
// jsdom has no layout engine, so the real react-resizable-panels would never
// produce meaningful sizes — record the props SessionView passes and expose a
// fake imperative handle per panel instead.
vi.mock("./ui/resizable", () => ({
ResizablePanelGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ResizableHandle: () => <div data-testid="resize-handle" />,
ResizablePanel: ({
children,
id,
defaultSize,
minSize,
maxSize,
collapsible,
panelRef,
onResize,
style: _style,
...rest
}: {
children?: ReactNode;
id: string;
defaultSize?: number | string;
minSize?: number | string;
maxSize?: number | string;
collapsible?: boolean;
panelRef?: Ref<FakePanelHandle | null>;
onResize?: (size: { asPercentage: number; inPixels: number }) => void;
style?: React.CSSProperties;
}) => {
let entry = panels.get(id);
if (!entry) {
entry = {
handle: {
collapse: vi.fn(),
expand: vi.fn(),
getSize: vi.fn(() => ({ asPercentage: 28, inPixels: 280 })),
isCollapsed: vi.fn(() => false),
resize: vi.fn(),
},
};
panels.set(id, entry);
}
entry.onResize = onResize;
if (panelRef && typeof panelRef === "object") {
(panelRef as { current: FakePanelHandle | null }).current = entry.handle;
}
return (
<div data-testid={`panel-${id}`} data-collapsible={collapsible ? "true" : undefined} {...rest}>
<span data-testid={`panel-${id}-sizes`}>
{JSON.stringify([defaultSize, minSize, maxSize].filter((s) => s !== undefined))}
</span>
{children}
</div>
);
},
}));
function panelSizes(id: string): unknown[] {
return JSON.parse(screen.getByTestId(`panel-${id}-sizes`).textContent ?? "[]") as unknown[];
}
describe("SessionView", () => {
beforeEach(() => {
window.localStorage.clear();
useUiStore.setState({ isInspectorOpen: true });
panels.clear();
});
// Regression: react-resizable-panels v4 treats bare numeric sizes as PIXELS
// (numbers were percentages in the older API the shadcn examples use).
// defaultSize={28}/maxSize={45} clamped the inspector rail to a 45px sliver.
// Every size must be an explicit percentage string.
it("sizes the terminal/inspector split in percentages, not pixels", () => {
render(<SessionView sessionId="sess-1" />);
for (const panelId of ["terminal", "inspector"]) {
const sizes = panelSizes(panelId);
expect(sizes.length).toBeGreaterThan(0);
for (const size of sizes) {
expect(size, `${panelId} size ${String(size)} must be a percentage string`).toMatch(/^\d+(\.\d+)?%$/);
}
}
});
it("marks the inspector collapsible and renders the resize handle", () => {
render(<SessionView sessionId="sess-1" />);
expect(screen.getByTestId("panel-inspector")).toHaveAttribute("data-collapsible", "true");
expect(screen.getByTestId("resize-handle")).toBeInTheDocument();
expect(screen.getByTestId("panel-inspector")).not.toHaveAttribute("inert");
});
it("mounts collapsed and inert when the store says closed", () => {
useUiStore.setState({ isInspectorOpen: false });
render(<SessionView sessionId="sess-1" />);
expect(panelSizes("inspector")[0]).toBe("0%");
const pane = screen.getByTestId("panel-inspector");
expect(pane).toHaveAttribute("inert");
expect(pane).toHaveAttribute("aria-hidden", "true");
expect(panels.get("inspector")!.handle.collapse).toHaveBeenCalled();
});
it("toggles the inspector with mod+shift+B through the imperative panel API", () => {
render(<SessionView sessionId="sess-1" />);
const handle = panels.get("inspector")!.handle;
fireEvent.keyDown(window, { key: "B", metaKey: true, shiftKey: true });
expect(useUiStore.getState().isInspectorOpen).toBe(false);
expect(handle.collapse).toHaveBeenCalledTimes(1);
fireEvent.keyDown(window, { key: "B", ctrlKey: true, shiftKey: true });
expect(useUiStore.getState().isInspectorOpen).toBe(true);
expect(handle.expand).toHaveBeenCalled();
// Plain ⌘B belongs to the sidebar — the inspector must not react.
fireEvent.keyDown(window, { key: "b", metaKey: true });
expect(useUiStore.getState().isInspectorOpen).toBe(true);
});
it("syncs drag resizes back into the store and persists the split", () => {
render(<SessionView sessionId="sess-1" />);
const entry = panels.get("inspector")!;
// Dragging past minSize collapses the panel → store follows.
act(() => entry.onResize?.({ asPercentage: 0, inPixels: 0 }));
expect(useUiStore.getState().isInspectorOpen).toBe(false);
// Dragging it back open reopens + persists the width.
act(() => entry.onResize?.({ asPercentage: 31.5, inPixels: 400 }));
expect(useUiStore.getState().isInspectorOpen).toBe(true);
expect(window.localStorage.getItem("ao.inspector.split")).toBe("31.5");
});
it("restores the persisted split width", () => {
window.localStorage.setItem("ao.inspector.split", "40");
render(<SessionView sessionId="sess-1" />);
expect(panelSizes("inspector")[0]).toBe("40%");
});
it("renders no inspector panel or handle for orchestrator sessions", () => {
render(<SessionView sessionId="sess-orch" />);
expect(screen.queryByTestId("panel-inspector")).not.toBeInTheDocument();
expect(screen.queryByTestId("resize-handle")).not.toBeInTheDocument();
// The shortcut is inactive without an inspector.
fireEvent.keyDown(window, { key: "B", metaKey: true, shiftKey: true });
expect(useUiStore.getState().isInspectorOpen).toBe(true);
});
});

View File

@ -0,0 +1,161 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
import { CenterPane } from "./CenterPane";
import { SessionInspector } from "./SessionInspector";
import { Topbar } from "./Topbar";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
import { useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { isOrchestratorSession } from "../types/workspace";
const INSPECTOR_MIN_PERCENT = 22;
const INSPECTOR_MAX_PERCENT = 45;
const inspectorSplitStorageKey = "ao.inspector.split";
function initialSplitPercent(): number {
const raw = typeof window === "undefined" ? null : window.localStorage?.getItem(inspectorSplitStorageKey);
const parsed = raw === null ? Number.NaN : Number(raw);
if (!Number.isFinite(parsed)) return 28;
return Math.min(INSPECTOR_MAX_PERCENT, Math.max(INSPECTOR_MIN_PERCENT, parsed));
}
type SessionViewProps = {
sessionId: string;
/** When entered via /projects/$projectId/sessions/... — used for the back-nav target. */
projectId?: string;
};
// The session detail screen: the persistent terminal + git rail. Rendered by
// both the project-scoped and cross-project session routes. The terminal lives
// here (not in the shell) — switching sessions only changes route params, so
// TanStack Router keeps this component mounted and the terminal re-points its
// mux without remounting (useTerminalSession). Leaving for the board unmounts
// it; the server's output ring replays on return.
//
// The split is shadcn's resizable (react-resizable-panels v4) with a fully
// collapsible inspector: the panel is `collapsible` and driven to 0% via the
// imperative API from the ui-store (Topbar button / ⌘⇧B), animated by the
// flex-grow transition in styles.css. Content keeps a stable min-width inside
// the clipped panel so nothing reflows mid-animation; split width persists.
export function SessionView({ sessionId, projectId }: SessionViewProps) {
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const { theme } = useUiStore();
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
const { daemonStatus } = useShell();
const inspectorRef = useRef<PanelImperativeHandle | null>(null);
const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false;
const workspace =
(session && workspaces.find((w) => w.id === session.workspaceId)) ??
(projectId ? workspaces.find((w) => w.id === projectId) : undefined);
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
const hasInspector = !isOrchestrator;
// Frozen at mount: rrp re-registers the panel (a layout effect keyed on
// defaultSize, among others) whenever this prop's identity changes, and the
// imperative collapse()/expand() below can race that re-registration within
// the same commit — rrp then throws "Panel constraints not found for Panel
// inspector", which unwinds the whole route to the router's CatchBoundary
// (the toggle button looks dead and the session view is torn down).
// defaultSize only matters at first mount; afterwards the imperative API
// owns the size, so it must never track live open/closed state.
const [inspectorDefaultSize] = useState(() => (isInspectorOpen ? `${initialSplitPercent()}%` : "0%"));
useEffect(() => {
if (!hasInspector) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== "b" || !event.shiftKey) return;
if (!event.metaKey && !event.ctrlKey) return;
event.preventDefault();
toggleInspector();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [hasInspector, toggleInspector]);
// Drive the collapsible panel from the store so the Topbar button, ⌘⇧B, and
// drag-to-collapse all stay in sync.
useEffect(() => {
const panel = inspectorRef.current;
if (!panel) return;
if (isInspectorOpen) {
panel.expand();
// expand() restores the "most recent" size, which is 0 when the panel
// mounted collapsed — fall back to the persisted split.
if (panel.getSize().asPercentage === 0) panel.resize(`${initialSplitPercent()}%`);
} else {
panel.collapse();
}
}, [hasInspector, isInspectorOpen]);
// Persist drags and mirror collapse state (dragging past minSize collapses)
// back into the store. Read the store imperatively to avoid a stale closure.
const handleInspectorResize = (size: PanelSize) => {
const open = useUiStore.getState().isInspectorOpen;
if (size.asPercentage > 0) {
window.localStorage?.setItem(inspectorSplitStorageKey, String(size.asPercentage));
if (!open) toggleInspector();
} else if (open) {
toggleInspector();
}
};
if (!session && !workspaceQuery.isLoading) {
return (
<div className="grid h-full place-items-center bg-background p-6 text-center font-mono text-[12px] text-passive">
Session not found. It may have been cleaned up pick another from the sidebar.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<Topbar
onOpenBoard={() =>
workspace
? void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } })
: void navigate({ to: "/" })
}
projectLabel={workspace?.name}
session={session}
view={isOrchestrator ? "orchestrator" : "session"}
/>
<ResizablePanelGroup className="session-split min-h-0 flex-1" id="session-workspace" orientation="horizontal">
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
be strings. Numeric sizes here once clamped the inspector to 45px. */}
<ResizablePanel defaultSize="72%" id="terminal" minSize="45%">
<CenterPane daemonReady={daemonStatus.state === "ready"} session={session} theme={theme} />
</ResizablePanel>
{hasInspector ? (
<>
<ResizableHandle className="session-inspector__resize-handle focus-visible:ring-0 focus-visible:ring-offset-0" />
<ResizablePanel
aria-hidden={!isInspectorOpen}
collapsible
defaultSize={inspectorDefaultSize}
id="inspector"
inert={!isInspectorOpen}
maxSize={`${INSPECTOR_MAX_PERCENT}%`}
minSize={`${INSPECTOR_MIN_PERCENT}%`}
onResize={handleInspectorResize}
panelRef={inspectorRef}
style={{ overflow: "hidden" }}
>
{/* Stable content width while the panel animates (yyork pattern):
the pane clips instead of reflowing the inspector mid-collapse. */}
<div className="h-full min-w-[280px]">
<SessionInspector session={session} />
</div>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</div>
);
}

View File

@ -0,0 +1,202 @@
import { useNavigate } from "@tanstack/react-router";
import {
type AttentionZone,
type WorkerDisplayStatus,
type WorkspaceSession,
attentionZone,
workerDisplayStatus,
workerSessions,
} from "../types/workspace";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { cn } from "../lib/utils";
type SessionsBoardProps = {
/** When set, the board shows only this project's sessions. */
projectId?: string;
};
// The four kanban columns, left→right by flow (work → review → merge), ported
// verbatim from agent-orchestrator (SIMPLE_KANBAN_LEVELS + AttentionZone +
// mc-board.css). "done" is archived, not a column.
type Column = {
level: AttentionZone;
label: string;
glow: string;
dot: string;
dotGlow: boolean;
titleClass: string;
};
const COLUMNS: Column[] = [
{
level: "working",
label: "Working",
glow: "color-mix(in srgb, var(--orange) 7%, transparent)",
dot: "var(--orange)",
dotGlow: true,
titleClass: "text-working",
},
{
level: "action",
label: "Needs you",
glow: "color-mix(in srgb, var(--amber) 6%, transparent)",
dot: "var(--amber)",
dotGlow: true,
titleClass: "text-warning",
},
{
level: "pending",
label: "In review",
glow: "var(--kanban-pending-glow)",
dot: "var(--fg-muted)",
dotGlow: false,
titleClass: "text-muted-foreground",
},
{
level: "merge",
label: "Ready to merge",
glow: "color-mix(in srgb, var(--green) 7%, transparent)",
dot: "var(--green)",
dotGlow: true,
titleClass: "text-success",
},
];
const BADGE: Record<WorkerDisplayStatus, { label: string; className: string }> = {
working: { label: "Working", className: "text-working" },
needs_you: { label: "Needs input", className: "text-warning" },
ci_failed: { label: "CI failed", className: "text-error" },
mergeable: { label: "Ready", className: "text-success" },
done: { label: "Done", className: "text-passive" },
};
export function SessionsBoard({ projectId }: SessionsBoardProps) {
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery();
const all = workspaceQuery.data ?? [];
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
const projectLabel = projectId ? (workspaces[0]?.name ?? projectId) : "agent-orchestrator";
const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) {
const zone = attentionZone(session);
(byZone.get(zone) ?? byZone.set(zone, []).get(zone)!).push(session);
}
const done = byZone.get("done") ?? [];
const openSession = (session: WorkspaceSession) =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId: session.workspaceId, sessionId: session.id },
});
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar activeTab="coding" projectId={projectId} projectLabel={projectLabel} />
<DashboardSubhead title="Board" subtitle="Live agent sessions flowing from work → review → merge." />
<div className="min-h-0 flex-1 overflow-hidden p-[18px]">
{workspaceQuery.isError ? (
<p className="py-10 text-center text-[12px] text-passive">Could not load sessions.</p>
) : (
<div className="grid h-full grid-cols-4 gap-2">
{COLUMNS.map((col) => (
<ZoneColumn key={col.level} col={col} sessions={byZone.get(col.level) ?? []} onOpen={openSession} />
))}
</div>
)}
</div>
{done.length > 0 && (
<div className="shrink-0 border-t border-border px-[18px] py-2.5">
<div className="mb-1.5 flex items-center gap-2">
<span className="h-[7px] w-[7px] rounded-full bg-passive" />
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Done</span>
<span className="font-mono text-[11px] text-passive">{done.length}</span>
</div>
<div className="flex flex-wrap gap-2">
{done.map((s) => (
<button
key={s.id}
className="rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-left transition-colors hover:border-border-strong"
onClick={() => openSession(s)}
type="button"
>
<span className="text-[12px] text-muted-foreground">{s.title}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
function ZoneColumn({
col,
sessions,
onOpen,
}: {
col: Column;
sessions: WorkspaceSession[];
onOpen: (s: WorkspaceSession) => void;
}) {
return (
<section
className="flex min-w-0 flex-col overflow-hidden rounded-[13px]"
style={{ background: `linear-gradient(180deg, ${col.glow}, transparent 130px), var(--kanban-column-bg)` }}
>
<div className="flex shrink-0 items-center gap-[9px] px-[15px] pb-[11px] pt-[14px]">
<span
className="h-[7px] w-[7px] rounded-full"
style={{
background: col.dot,
boxShadow: col.dotGlow ? `0 0 7px color-mix(in srgb, ${col.dot} 60%, transparent)` : undefined,
}}
/>
<span className={cn("text-[11px] font-semibold uppercase tracking-[0.08em]", col.titleClass)}>{col.label}</span>
<span className="ml-auto font-mono text-[11px] leading-none text-passive">{sessions.length}</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[11px] pb-3">
<div className="flex flex-col gap-2.5">
{sessions.map((session) => (
<SessionCard key={session.id} session={session} onOpen={() => onOpen(session)} />
))}
</div>
</div>
</section>
);
}
function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) {
const badge = BADGE[workerDisplayStatus(session)];
const branch = session.branch || `session/${session.id}`;
return (
<button
className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong"
onClick={onOpen}
type="button"
>
<div className="flex items-center gap-2 px-[13px] pb-[9px] pt-3">
<span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium", badge.className)}>
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
{badge.label}
</span>
<span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">{session.id}</span>
</div>
<div
className={cn(
"px-[13px] pb-2.5 text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground",
"line-clamp-2 overflow-hidden",
)}
>
{session.title}
</div>
<div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>
<div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive">
{session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
</div>
</button>
);
}

View File

@ -1,206 +0,0 @@
import { GitBranch, GitCommitHorizontal, GitPullRequest, LoaderCircle, Plus, Square, Trash2 } from "lucide-react";
import type { WorkbenchView } from "../stores/ui-store";
import {
type WorkerDisplayStatus,
type WorkspaceSession,
type WorkspaceSummary,
workerDisplayStatus,
workerStatusLabel,
workerStatusPulses,
} from "../types/workspace";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
// Session status is a single glyph, no text: spinner while working, a PR icon
// when there's a PR, otherwise a colored dot. See DESIGN.md.
const dotTone: Record<WorkerDisplayStatus, string> = {
working: "bg-accent",
needs_you: "bg-warning",
mergeable: "bg-success",
ci_failed: "bg-error",
done: "bg-passive",
};
const prTone: Record<WorkerDisplayStatus, string> = {
working: "text-accent",
needs_you: "text-warning",
mergeable: "text-success",
ci_failed: "text-error",
done: "text-muted-foreground",
};
function StatusGlyph({ worker }: { worker: WorkspaceSession }) {
const status = workerDisplayStatus(worker);
let glyph: React.ReactNode;
if (status === "working") {
glyph = <LoaderCircle className="h-3.5 w-3.5 animate-spin text-accent" aria-hidden="true" />;
} else if (worker.pullRequest) {
glyph = <GitPullRequest className={cn("h-3.5 w-3.5", prTone[status])} aria-hidden="true" />;
} else {
glyph = (
<span
className={cn(
"h-[7px] w-[7px] rounded-full",
dotTone[status],
workerStatusPulses(status) && "animate-status-pulse",
)}
/>
);
}
return (
<span className="grid h-3.5 w-3.5 shrink-0 place-items-center" title={workerStatusLabel[status]}>
{glyph}
</span>
);
}
type SideRailProps = {
view: WorkbenchView;
session?: WorkspaceSession;
workspaces: WorkspaceSummary[];
onSelectSession: (sessionId: string, workspaceId: string) => void;
};
export function SideRail({ view, session, workspaces, onSelectSession }: SideRailProps) {
return (
<aside className="flex h-full w-[316px] shrink-0 flex-col border-l border-border bg-background">
{view === "orchestrator" ? (
<WorkersList workspaces={workspaces} onSelectSession={onSelectSession} />
) : (
<GitRail session={session} />
)}
</aside>
);
}
function WorkersList({
workspaces,
onSelectSession,
}: {
workspaces: WorkspaceSummary[];
onSelectSession: (sessionId: string, workspaceId: string) => void;
}) {
const workers = workspaces.flatMap((workspace) => workspace.sessions);
return (
<>
<SideHead title="Workers" count={workers.length} />
<div className="min-h-0 flex-1 overflow-y-auto">
{workers.length === 0 ? (
<p className="px-3 py-6 text-center text-[12px] text-passive">No workers yet.</p>
) : (
workers.map((worker) => {
const pr = worker.pullRequest;
const subtitle = [worker.workspaceName, pr ? `PR #${pr.number}` : worker.branch]
.filter(Boolean)
.join(" · ");
return (
<button
className="flex h-10 w-full items-center gap-2.5 border-b border-border/50 px-3 text-left transition-colors hover:bg-surface"
key={worker.id}
onClick={() => onSelectSession(worker.id, worker.workspaceId)}
type="button"
>
<StatusGlyph worker={worker} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">{worker.title}</span>
<span className="block truncate font-mono text-[10px] text-passive">{subtitle}</span>
</span>
</button>
);
})
)}
</div>
</>
);
}
function GitRail({ session }: { session?: WorkspaceSession }) {
const files = session?.changedFiles ?? [];
return (
<>
<SideHead title="Changed" count={files.length} />
<div className="flex items-center gap-3 border-b border-border px-3 py-2 text-[12px]">
<button className="text-muted-foreground transition-colors hover:text-foreground" type="button">
All files
</button>
<button
className="inline-flex items-center gap-1.5 text-error transition-colors hover:opacity-80"
type="button"
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
Discard all
</button>
<button
className="ml-auto inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Plus className="h-3 w-3" aria-hidden="true" />
Stage all
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{files.length === 0 ? (
<p className="px-2 py-6 text-center text-[12px] text-passive">No changes yet.</p>
) : (
files.map((file) => (
<div
className="flex h-7 items-center gap-2 rounded-md px-2 font-mono text-[12px] text-muted-foreground transition-colors hover:bg-surface"
key={file.path}
>
<span className="min-w-0 flex-1 truncate text-foreground">{file.path}</span>
<span className="shrink-0 text-success">+{file.additions}</span>
<span className="shrink-0 text-error">{file.deletions}</span>
<Square
className={cn("h-[13px] w-[13px] shrink-0", file.staged ? "text-accent" : "text-passive")}
aria-hidden="true"
/>
</div>
))
)}
</div>
<div className="flex flex-col gap-2 border-t border-border p-3">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-[12.5px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
defaultValue={session?.commitMessage ?? ""}
key={session?.id}
placeholder="Commit message"
/>
<textarea
className="w-full resize-none rounded-md border border-border bg-transparent px-2.5 py-1.5 text-[12.5px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
placeholder="Description"
rows={2}
/>
<Button className="w-full" disabled={files.length === 0} variant="primary">
<GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
Commit &amp; Push
</Button>
</div>
<div className="flex items-center gap-2.5 border-t border-border px-3 py-2 font-mono text-[11px] text-passive">
<GitBranch className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate text-muted-foreground">{session?.branch || "—"}</span>
<button
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-muted-foreground transition-colors hover:bg-surface"
type="button"
>
<Plus className="h-3 w-3" aria-hidden="true" />
<GitPullRequest className="h-3 w-3" aria-hidden="true" />
Create PR
</button>
</div>
</>
);
}
function SideHead({ title, count }: { title: string; count: number }) {
return (
<div className="flex h-[38px] shrink-0 items-center gap-2 border-b border-border px-3">
<span className="text-[13px] font-semibold text-foreground">{title}</span>
<span className="font-mono text-[11px] text-passive">{count}</span>
</div>
);
}

View File

@ -1,11 +1,47 @@
import { ChevronsUpDown, Folder, Plus, Search, Settings, Waypoints } from "lucide-react";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react";
import { useState } from "react";
import { sessionIsActive, sessionNeedsAttention, type WorkspaceSummary } from "../types/workspace";
import { useUiStore } from "../stores/ui-store";
import { attentionZone, type WorkspaceSession, type WorkspaceSummary, workerSessions } from "../types/workspace";
import { aoBridge } from "../lib/bridge";
import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
Sidebar as SidebarRoot,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarTrigger,
useSidebar,
} from "./ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store";
// macOS hiddenInset traffic lights (x:14, y:14) occupy the sidebar's top-left;
// the sidebar gives them a real 40px titlebar strip (draggable; the fixed
// TitlebarNav overlay sits beside the lights), and the collapsed icon rail
// keeps a matching 40px inset. Windows/Linux keep the verbatim 14px padding.
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type SidebarProps = {
daemonStatus: { state: string; message?: string };
@ -15,155 +51,370 @@ type SidebarProps = {
onNewWorker: (projectId: string) => void;
};
function fleetSummary(workspaces: WorkspaceSummary[]) {
const sessions = workspaces.flatMap((workspace) => workspace.sessions);
const agents = sessions.filter(sessionIsActive).length;
const needYou = sessions.filter(sessionNeedsAttention).length;
return { agents, needYou };
// Selection state comes from the URL: which project/session is active is the
// route params, and clicks navigate rather than mutate a store.
function useSelection() {
const navigate = useNavigate();
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const pathname = useRouterState({ select: (state) => state.location.pathname });
return {
isHome: pathname === "/",
activeProjectId: params.projectId,
activeSessionId: params.sessionId,
goHome: () => void navigate({ to: "/" }),
goPrs: () => void navigate({ to: "/prs" }),
goReview: () => void navigate({ to: "/review" }),
goSettings: (projectId: string) => void navigate({ to: "/projects/$projectId/settings", params: { projectId } }),
goProject: (projectId: string) => void navigate({ to: "/projects/$projectId", params: { projectId } }),
goSession: (projectId: string, sessionId: string) =>
void navigate({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId } }),
};
}
export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject, onNewWorker }: SidebarProps) {
const {
isSidebarOpen,
view,
selectedSessionId,
selectedWorkspaceId,
selectOrchestrator,
selectSession,
selectWorkspace,
} = useUiStore();
const { agents, needYou } = fleetSummary(workspaces);
const eventsConnection = useEventsConnection();
if (!isSidebarOpen) {
// agent-orchestrator's SessionDot: 6px dot, neutral grey at rest, orange +
// breathe while the agent is working. Other attention zones stay neutral here
// (the board carries the richer colour coding).
function SessionDot({ session }: { session: WorkspaceSession }) {
const working = attentionZone(session) === "working";
return (
<CollapsedRail agents={agents} needYou={needYou} onCreateProject={onCreateProject} workspaces={workspaces} />
<span
aria-hidden="true"
className={cn(
"mt-px h-1.5 w-1.5 shrink-0 rounded-full",
working ? "animate-status-pulse bg-working" : "bg-passive",
)}
/>
);
}
// Built on shadcn's sidebar primitives (components/ui/sidebar): the provider in
// _shell owns open state (synced to the ui-store) and `collapsible="icon"`
// replaces the old hand-rolled CollapsedRail — the same tree restyles itself
// via group-data-[collapsible=icon] into the 48px letter rail.
export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject, onNewWorker }: SidebarProps) {
const selection = useSelection();
const eventsConnection = useEventsConnection();
const { state } = useSidebar();
const theme = useUiStore((s) => s.theme);
const toggleTheme = useUiStore((s) => s.toggleTheme);
// Disclosure state: projects are expanded by default; a project id present in
// this set is collapsed (sessions hidden).
const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(() => new Set());
const toggleCollapsed = (id: string) =>
setCollapsedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
// agent-orchestrator's sidebar resize: drag the right edge (200420px,
// persisted), double-click to reset to 240px. Drives --ao-sidebar-w on :root,
// which the provider forwards into shadcn's --sidebar-width.
const { onPointerDown: onResizePointerDown, onDoubleClick: onResizeDoubleClick } = useResizable({
cssVar: "--ao-sidebar-w",
storageKey: "ao-sidebar-w",
defaultWidth: 240,
min: 200,
max: 420,
edge: "right",
});
return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground">
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{/* Orchestrator anchor — ReverbCode's one addition over emdash. */}
<SidebarRoot collapsible="icon" className="border-border">
<SidebarHeader className={cn("gap-0 p-0 px-[7px] group-data-[collapsible=icon]:px-1.5", !isMac && "pt-3.5")}>
{/* Titlebar strip: a draggable 40px inset under the traffic lights and
the fixed TitlebarNav overlay (rendered once by the shell), kept in
both sidebar states. */}
{isMac && <div className="h-10 shrink-0" style={dragStyle} />}
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */}
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Orchestrator"
aria-label="Orchestrator board"
className={cn(
"group relative mb-2 flex h-[38px] w-full items-center gap-2.5 rounded-lg pl-3 pr-2.5 text-left transition-colors",
"before:absolute before:left-0 before:top-2 before:bottom-2 before:w-0.5 before:rounded-r before:bg-accent",
view === "orchestrator" ? "bg-overlay" : "bg-raised hover:bg-overlay",
"grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[6px] bg-accent text-accent-foreground",
"group-data-[collapsible=icon]:size-9 group-data-[collapsible=icon]:rounded-lg group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:text-current",
selection.isHome
? "group-data-[collapsible=icon]:bg-interactive-active"
: "group-data-[collapsible=icon]:hover:bg-interactive-hover",
)}
onClick={selectOrchestrator}
onClick={selection.goHome}
type="button"
>
<Waypoints className="h-4 w-4 shrink-0 text-accent" aria-hidden="true" />
<span className="text-[13.5px] font-semibold text-foreground">Orchestrator</span>
<span className="ml-auto whitespace-nowrap font-mono text-[10px] text-passive">{agents} agents</span>
<span className="contents group-data-[collapsible=icon]:grid group-data-[collapsible=icon]:h-[22px] group-data-[collapsible=icon]:w-[22px] group-data-[collapsible=icon]:place-items-center group-data-[collapsible=icon]:rounded-[6px] group-data-[collapsible=icon]:bg-accent group-data-[collapsible=icon]:text-accent-foreground">
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" hidden={state !== "collapsed"}>
Orchestrator board
</TooltipContent>
</Tooltip>
<span className="min-w-0 flex-1 truncate text-[14px] font-bold tracking-[-0.015em] text-foreground group-data-[collapsible=icon]:hidden">
Agent Orchestrator
</span>
{/* On macOS the toggle lives in the titlebar cluster instead. */}
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger className="shrink-0 rounded-md text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]" />
</TooltipTrigger>
<TooltipContent>Collapse sidebar · B</TooltipContent>
</Tooltip>
)}
</div>
</SidebarHeader>
<div className="flex h-[34px] items-center gap-1.5 px-2">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-passive">Projects</span>
<SidebarContent className="gap-0 px-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5">
<SidebarGroup className="p-0">
{/* Section label (project-sidebar__nav-label) */}
<div className="flex shrink-0 items-center justify-between px-2 pb-2 group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="h-auto rounded-none p-0 text-[10.5px] font-semibold uppercase tracking-[0.09em] text-passive">
Projects
</SidebarGroupLabel>
<CreateProjectButton onCreateProject={onCreateProject} />
</div>
{/* Tree (project-sidebar__tree) */}
<SidebarGroupContent>
{workspaceError ? (
<div className="px-3 py-4">
<div className="px-2 py-3 group-data-[collapsible=icon]:hidden">
<p className="text-[12px] text-foreground">Could not load projects.</p>
<p className="mt-1 text-[11px] text-passive">{workspaceError}</p>
</div>
) : workspaces.length === 0 ? (
<div className="px-3 py-4">
<div className="px-2 py-3 group-data-[collapsible=icon]:hidden">
<p className="text-[12px] text-passive">No projects yet.</p>
<p className="mt-1 text-[11px] text-passive">
Click <span className="text-foreground">+</span> above to register a git repo.
</p>
</div>
) : (
workspaces.map((workspace) => (
<section key={workspace.id} className="mb-1">
<div
className={cn(
"group flex h-8 w-full items-center rounded-lg pr-1 transition-colors",
selectedWorkspaceId === workspace.id && view !== "session"
? "bg-raised text-foreground"
: "text-muted-foreground hover:bg-surface",
<SidebarMenu className="gap-0 group-data-[collapsible=icon]:gap-1">
{workspaces.map((workspace) => (
<ProjectItem
key={workspace.id}
workspace={workspace}
expanded={!collapsedIds.has(workspace.id)}
selection={selection}
onToggle={() => toggleCollapsed(workspace.id)}
onNewWorker={() => onNewWorker(workspace.id)}
/>
))}
</SidebarMenu>
)}
>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/* Footer (project-sidebar__footer) single Settings menu. Divergence
(user-requested 2026-06-10): the trigger stretches the full row width
(flex-1) with a uniform 7px footer inset on all sides (reference uses
12px top, 0 bottom, content-hugging button). The icon rail swaps it
for the old rail footer: New project (+ expand toggle off macOS). */}
<SidebarFooter className="mt-auto gap-0 border-t border-border p-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5 group-data-[collapsible=icon]:pb-0 group-data-[collapsible=icon]:pt-2">
<div className="relative flex w-full items-center group-data-[collapsible=icon]:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Select ${workspace.name}`}
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left"
onClick={() => selectWorkspace(workspace.id)}
aria-label="Settings"
className="flex flex-1 items-center justify-start gap-2.5 rounded-md p-2 text-[13px] font-medium text-passive transition-colors hover:bg-interactive-hover hover:text-foreground data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px] [&_svg]:text-passive"
type="button"
>
<Folder className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[13.5px]">{workspace.name}</span>
<Settings aria-hidden="true" />
<span className="tracking-[-0.01em]">Settings</span>
</button>
<NewWorkerButton
onClick={() => onNewWorker(workspace.id)}
projectName={workspace.name}
visible={selectedWorkspaceId === workspace.id && view !== "session"}
/>
</div>
{workspace.sessions.map((session) => {
const active = view === "session" && selectedSessionId === session.id;
return (
<button
aria-label={session.title}
className={cn(
"relative flex h-8 w-full items-center rounded-lg pl-[30px] pr-2 text-left transition-colors",
active
? "bg-raised text-foreground before:absolute before:left-5 before:top-2 before:bottom-2 before:w-0.5 before:rounded before:bg-accent"
: "text-muted-foreground hover:bg-surface",
)}
key={session.id}
onClick={() => selectSession(session.id, workspace.id)}
type="button"
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
side="top"
>
<span className="min-w-0 flex-1 truncate text-[13.5px]">{session.title}</span>
</button>
);
})}
</section>
))
<DropdownMenuItem onSelect={toggleTheme}>
{theme === "dark" ? <Sun aria-hidden="true" /> : <Moon aria-hidden="true" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={selection.goPrs}>
<GitPullRequest aria-hidden="true" />
Pull requests
</DropdownMenuItem>
<DropdownMenuItem onSelect={selection.goReview}>
<Settings aria-hidden="true" />
Reviews
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Search aria-hidden="true" />
Search
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
{selection.activeProjectId && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => selection.goSettings(selection.activeProjectId!)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={`Daemon ${daemonStatus.state}`}
className={cn(
"absolute right-1.5 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full",
daemonStatus.state === "running" && eventsConnection !== "disconnected" ? "bg-success" : "bg-amber",
)}
</div>
<div className="border-t border-border p-2">
<FooterRow icon={<Search className="h-[15px] w-[15px]" aria-hidden="true" />} label="Search" shortcut="⌘K" />
<FooterRow
icon={<Settings className="h-[15px] w-[15px]" aria-hidden="true" />}
label="Settings"
shortcut="⌘,"
/>
</div>
<div className="flex items-center gap-2.5 border-t border-border px-2.5 py-2">
<span className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-md bg-accent text-[11px] font-semibold text-accent-foreground">
R
</span>
<div className="min-w-0">
<div className="truncate text-[12.5px] text-foreground">ReverbCode</div>
<div className="truncate font-mono text-[10px] text-passive">
</TooltipTrigger>
<TooltipContent side="top">
daemon {daemonStatus.state}
{eventsConnection === "disconnected" && " · events offline"}
</TooltipContent>
</Tooltip>
</div>
<div className="hidden flex-col items-center gap-1 pb-3.5 group-data-[collapsible=icon]:flex">
<CreateProjectButton onCreateProject={onCreateProject} />
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger className="size-9 rounded-lg text-passive hover:bg-interactive-hover hover:text-foreground [&_svg]:size-4" />
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar · B</TooltipContent>
</Tooltip>
)}
</div>
<ChevronsUpDown className="ml-auto h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
</div>
</aside>
</SidebarFooter>
<div
className="resize-handle resize-handle--right group-data-[collapsible=icon]:hidden"
onPointerDown={onResizePointerDown}
onDoubleClick={onResizeDoubleClick}
style={noDragStyle}
/>
</SidebarRoot>
);
}
function FooterRow({ icon, label, shortcut }: { icon: React.ReactNode; label: string; shortcut: string }) {
type Selection = ReturnType<typeof useSelection>;
function ProjectItem({
workspace,
expanded,
selection,
onToggle,
onNewWorker,
}: {
workspace: WorkspaceSummary;
expanded: boolean;
selection: Selection;
onToggle: () => void;
onNewWorker: () => void;
}) {
const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId;
const onProjectClick = () => {
if (!expanded) {
onToggle();
selection.goProject(workspace.id);
} else if (projectActive) {
onToggle();
} else {
selection.goProject(workspace.id);
}
};
return (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
{/* project-sidebar__proj-row */}
<SidebarMenuButton
aria-current={projectActive ? "page" : undefined}
aria-expanded={expanded}
isActive={projectActive}
onClick={onProjectClick}
tooltip={workspace.name}
className={cn(
"h-auto gap-[9px] rounded-[5px] px-1.5 py-[7px] text-[13px] font-medium text-muted-foreground transition-[padding]",
"hover:bg-interactive-hover hover:text-muted-foreground active:bg-interactive-hover active:text-muted-foreground",
"data-[active=true]:bg-interactive-active data-[active=true]:font-semibold data-[active=true]:text-foreground",
// The count badge sits in-flow (verbatim layout), so undo the
// variant's blanket action padding; hover makes room for the + only.
"group-has-data-[sidebar=menu-action]/menu-item:pr-1.5 group-hover/menu-item:pr-[34px]",
// Icon rail: the old 36px letter tile.
"group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-lg group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:font-semibold",
)}
>
<ChevronRight
className={cn(
"h-[9px]! w-[9px]! shrink-0 text-passive transition-transform group-data-[collapsible=icon]:hidden",
expanded && "rotate-90",
)}
strokeWidth={2.5}
aria-hidden="true"
/>
<span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span>
<span className="min-w-0 flex-1 truncate group-data-[collapsible=icon]:hidden">{workspace.name}</span>
<span className="shrink-0 font-mono text-[11px] text-passive group-hover/menu-item:opacity-0 group-data-[collapsible=icon]:hidden">
{workerSessions(workspace.sessions).length}
</span>
</SidebarMenuButton>
{/* project-sidebar__proj-actions — reveal over the count slot on hover */}
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuAction
showOnHover
aria-label={`New worker in ${workspace.name}`}
onClick={onNewWorker}
className="right-1.5 h-[22px] w-[22px] rounded-[5px] text-passive transition-opacity hover:bg-interactive-active hover:text-foreground peer-data-[size=default]/menu-button:top-1"
>
<Plus className="h-[13px]! w-[13px]!" aria-hidden="true" />
</SidebarMenuAction>
</TooltipTrigger>
<TooltipContent>New worker in {workspace.name}</TooltipContent>
</Tooltip>
{/* project-sidebar__sessions */}
{expanded && workerSessions(workspace.sessions).length > 0 && (
<SidebarMenuSub className="mx-0 translate-x-0 gap-0 border-0 px-0 pb-2 pl-1 pt-0.5">
{workerSessions(workspace.sessions).map((session) => {
const active = selection.activeSessionId === session.id;
return (
<SidebarMenuSubItem key={session.id}>
<SidebarMenuSubButton asChild isActive={active}>
<button
className="flex h-7 w-full items-center gap-2.5 rounded-lg px-2 text-left text-[13px] text-muted-foreground transition-colors hover:bg-surface [&_svg]:text-passive"
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
className={cn(
"h-auto w-full translate-x-0 gap-[9px] rounded-[5px] py-[5px] pl-2 pr-1.5 text-left transition-colors",
"hover:bg-interactive-hover data-[active=true]:bg-interactive-active",
)}
onClick={() => selection.goSession(workspace.id, session.id)}
type="button"
>
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="font-mono text-[10px] text-passive">{shortcut}</span>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[12px]",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{session.title}
</span>
<span className="block truncate font-mono text-[10px] text-passive">{session.id}</span>
</span>
</button>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
@ -190,7 +441,7 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
<TooltipTrigger asChild>
<button
aria-label="New project"
className="ml-auto grid h-6 w-6 place-items-center rounded-md text-passive transition-colors hover:bg-surface hover:text-foreground"
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={isChoosingPath}
onClick={choosePath}
type="button"
@ -208,103 +459,3 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
</>
);
}
function NewWorkerButton({
onClick,
projectName,
visible,
}: {
onClick: () => void;
projectName: string;
visible: boolean;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`New worker in ${projectName}`}
className={cn(
"grid h-6 w-6 shrink-0 place-items-center rounded-md text-passive transition-all hover:bg-overlay hover:text-foreground group-hover:opacity-100",
visible ? "opacity-100" : "opacity-0",
)}
onClick={onClick}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>New worker in {projectName}</TooltipContent>
</Tooltip>
);
}
function CollapsedRail({
agents,
needYou,
workspaces,
onCreateProject,
}: {
agents: number;
needYou: number;
workspaces: WorkspaceSummary[];
onCreateProject: SidebarProps["onCreateProject"];
}) {
const { view, selectedWorkspaceId, selectOrchestrator, selectWorkspace } = useUiStore();
return (
<aside className="flex h-full w-12 shrink-0 flex-col items-center border-r border-border bg-sidebar py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Orchestrator"
className={cn(
"relative grid h-9 w-9 place-items-center rounded-lg transition-colors",
"before:absolute before:left-0 before:top-2 before:bottom-2 before:w-0.5 before:rounded-r before:bg-accent",
view === "orchestrator" ? "bg-overlay" : "bg-raised hover:bg-overlay",
)}
onClick={selectOrchestrator}
type="button"
>
<Waypoints className="h-4 w-4 text-accent" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
Orchestrator · {agents} agents · {needYou} need you
</TooltipContent>
</Tooltip>
<div className="mt-2 flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto">
{workspaces.map((workspace) => (
<Tooltip key={workspace.id}>
<TooltipTrigger asChild>
<button
aria-label={workspace.name}
className={cn(
"grid h-9 w-9 place-items-center rounded-lg transition-colors",
selectedWorkspaceId === workspace.id && view !== "session"
? "bg-raised text-foreground"
: "text-muted-foreground hover:bg-surface",
)}
onClick={() => selectWorkspace(workspace.id)}
type="button"
>
<Folder className="h-4 w-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{workspace.name}</TooltipContent>
</Tooltip>
))}
</div>
<div className="flex flex-col items-center gap-1 border-t border-border pt-2">
<CreateProjectButton onCreateProject={onCreateProject} />
<button
aria-label="Search"
className="grid h-9 w-9 place-items-center rounded-lg text-passive transition-colors hover:bg-surface hover:text-foreground"
type="button"
>
<Search className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</aside>
);
}

View File

@ -1,8 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SpawnWorkerModal } from "./SpawnWorkerModal";
import { TooltipProvider } from "./ui/tooltip";
import { SpawnWorkerModal } from "./SpawnWorkerModal";
import type { WorkspaceSummary } from "../types/workspace";
const workspaces: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", type: "main", sessions: [] }];
@ -23,12 +23,30 @@ function renderModal(onCreateTask = vi.fn().mockResolvedValue(undefined), onOpen
}
describe("SpawnWorkerModal", () => {
it("requires a non-empty prompt before it can spawn", () => {
// Regression: "Based on main" must NOT send branch:"main" — git refuses a
// second worktree on a checked-out branch, so the daemon 409s. Omitting it
// lets the daemon mint a fresh ao/<sessionId>.
it("omits the base branch from the spawn payload", async () => {
const user = userEvent.setup();
const onCreateTask = renderModal();
await user.type(await screen.findByLabelText("Prompt"), "do the thing");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
expect(onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ projectId: "proj-1", prompt: "do the thing", branch: undefined }),
);
});
it("requires a non-empty prompt before it can spawn", async () => {
const onCreateTask = renderModal();
expect(screen.getByRole("button", { name: /Spawn worker/ })).toBeDisabled();
expect(onCreateTask).not.toHaveBeenCalled();
});
// Regression: a failed spawn (e.g. 409 BRANCH_CHECKED_OUT_ELSEWHERE) must
// keep the modal open with the daemon's message inline and the input intact,
// disable submit only while in flight, and allow re-submitting.
it("keeps the modal open and shows the daemon error when the spawn fails", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();

View File

@ -18,6 +18,9 @@ const agentOptions: { value: AgentProvider; label: string }[] = [
];
const basedOnTabs = ["Branch", "Issue", "Pull Request"] as const;
// The project's default branch — selecting it in "Based on" means "new session
// branch off the default", not "check out the default branch itself".
const BASE_BRANCH = "main";
type BasedOn = (typeof basedOnTabs)[number];
const NAME_RULE = /^[a-z0-9-]+$/;
@ -27,7 +30,12 @@ type SpawnWorkerModalProps = {
onOpenChange: (open: boolean) => void;
workspaces: WorkspaceSummary[];
defaultProjectId?: string;
onCreateTask: (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => Promise<void>;
onCreateTask: (input: {
projectId: string;
prompt: string;
branch?: string;
harness?: AgentProvider;
}) => Promise<void>;
};
export function SpawnWorkerModal({
@ -42,6 +50,7 @@ export function SpawnWorkerModal({
const [projectId, setProjectId] = useState(fallbackProjectId);
const [agent, setAgent] = useState<AgentProvider>("claude-code");
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
const [branch, setBranch] = useState(BASE_BRANCH);
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
const [prompt, setPrompt] = useState("");
const [error, setError] = useState<string | null>(null);
@ -56,6 +65,9 @@ export function SpawnWorkerModal({
}, [open, fallbackProjectId]);
const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
const branchOptions = Array.from(
new Set([BASE_BRANCH, ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]),
);
const nameValid = name === "" || NAME_RULE.test(name);
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;
@ -65,14 +77,23 @@ export function SpawnWorkerModal({
setError(null);
setIsSubmitting(true);
try {
// The API's `branch` field means "check out this exact branch in the
// session worktree" — valid for resuming an existing session branch, but
// never for the base branch itself (git refuses a second worktree on a
// checked-out branch; daemon manager.go). "Based on main" therefore
// OMITS branch so the daemon mints a fresh ao/<sessionId> off the
// project's default branch.
const trimmedBranch = branch.trim();
await onCreateTask({
projectId,
prompt: prompt.trim(),
name: name || undefined,
branch:
basedOn === "Branch" && trimmedBranch !== "" && trimmedBranch !== BASE_BRANCH ? trimmedBranch : undefined,
harness: agent,
});
setName("");
setPrompt("");
setBranch(BASE_BRANCH);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not spawn worker");
@ -159,15 +180,19 @@ export function SpawnWorkerModal({
</div>
</div>
<div className="p-2.5">
{basedOn === "Branch" ? (
<SelectControl
aria-label="Based on branch"
className="flex w-full"
onChange={setBranch}
value={branch}
options={branchOptions.map((option) => ({ value: option, label: option }))}
/>
) : (
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{/* The API has no per-spawn base branch the worker branches off the
project's configured default branch in a fresh worktree. */}
{basedOn === "Branch"
? "Branches off the project's default branch in a fresh worktree."
: basedOn === "Issue"
? "Pick an issue to start from."
: "Pick a pull request to start from."}
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
</p>
)}
</div>
</div>

View File

@ -1,13 +1,8 @@
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { CanvasAddon } from "@xterm/addon-canvas";
import { WebglAddon } from "@xterm/addon-webgl";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useCallback, useEffect, useRef, useState } from "react";
import type { WorkspaceSession } from "../types/workspace";
import type { Theme } from "../stores/ui-store";
import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession";
import { XtermTerminal } from "./XtermTerminal";
type TerminalPaneProps = {
session?: WorkspaceSession;
@ -31,37 +26,7 @@ export function TerminalPane({ session, theme, daemonReady }: TerminalPaneProps)
);
}
return <XtermTerminal session={session} theme={theme} daemonReady={daemonReady} />;
}
function webgl2Available(): boolean {
try {
return Boolean(document.createElement("canvas").getContext("webgl2"));
} catch {
return false;
}
}
// Load the GPU-accelerated WebGL renderer when a real WebGL2 context is
// available, falling back to the 2D canvas renderer otherwise (software
// rendering, older GPUs). Probing first avoids loading a half-initialised
// WebglAddon that then throws on dispose. Renderer addons load after open().
function attachRenderer(terminal: Terminal): void {
if (webgl2Available()) {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
terminal.loadAddon(webgl);
return;
} catch {
// WebGL init failed despite the probe; fall through to canvas.
}
}
try {
terminal.loadAddon(new CanvasAddon());
} catch {
// The renderer addon is an optimisation; the DOM renderer still works.
}
return <AttachedTerminal session={session} theme={theme} daemonReady={daemonReady} />;
}
function bannerText(state: TerminalSessionState, error?: string): string | undefined {
@ -70,103 +35,64 @@ function bannerText(state: TerminalSessionState, error?: string): string | undef
return undefined;
}
const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H";
function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
function AttachedTerminal({ session, theme, daemonReady }: TerminalPaneProps) {
// One terminal instance per pane lifetime (yyork's core rule): switching
// sessions never remounts XtermTerminal — the attachment effect re-points
// the mux and clears the screen instead. A keyed remount would tear down the
// renderer mid-switch and lose the warm GPU surface.
const [terminal, setTerminal] = useState<AttachableTerminal | null>(null);
const [initFailed, setInitFailed] = useState(false);
const { attach, state, error } = useTerminalSession(session, { daemonReady });
const handleId = session?.terminalHandleId;
const hadAttachmentRef = useRef(false);
const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []);
const handleInitError = useCallback((err: unknown) => {
console.error("xterm failed to initialize", err);
setInitFailed(true);
}, []);
useEffect(() => {
if (!containerRef.current) return;
const terminal = new Terminal({
allowProposedApi: false,
cursorBlink: true,
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
lineHeight: 1.35,
// Zellij owns scrollback inside the attached pane; keeping xterm
// scrollback creates a dead scrollbar beside the alt-buffer app.
scrollback: 0,
theme: terminalTheme(theme),
});
terminalRef.current = terminal;
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon());
terminal.open(containerRef.current);
attachRenderer(terminal);
let detach: (() => void) | undefined;
let rafId: number | undefined;
// The attachment forwards size changes itself (terminal.onResize → mux);
// the component only owns fitting the terminal to its container.
const fitTerminal = () => {
if (!containerRef.current?.clientWidth || !containerRef.current.clientHeight) return;
try {
fitAddon.fit();
} catch {
// Electron can report zero-sized panels during startup; the next resize will retry.
if (!terminal) return;
// Reuse means the previous session's screen would linger; clear before
// re-pointing. Screen-clear only, never reset(): every pane PTY is
// `zellij attach` with identical modes, and a full RIS would wipe the
// mouse-tracking mode zellij enabled at attach — the 50KB ring replay
// can't re-enable it, leaving wheel scroll dead after the first session
// switch (yyork's frozen-scroll regression, solved there the same way).
// Skipped on the very first attachment: the buffer is empty and the first
// fit may not have run yet.
if (hadAttachmentRef.current) {
terminal.clear();
}
};
hadAttachmentRef.current = true;
return attach(terminal);
}, [terminal, handleId, attach]);
if (session?.terminalHandleId) {
rafId = requestAnimationFrame(() => {
fitTerminal();
const attachable: AttachableTerminal = {
get cols() {
return terminal.cols;
},
get rows() {
return terminal.rows;
},
write: (data) => terminal.write(data),
writeln: (line) => terminal.writeln(line),
clear: () => terminal.write(CLEAR_SEQUENCE),
onData: (listener) => terminal.onData(listener),
onResize: (listener) => terminal.onResize(listener),
};
detach = attach(attachable);
});
} else {
rafId = requestAnimationFrame(fitTerminal);
terminal.writeln("Agent Orchestrator");
terminal.writeln("");
terminal.writeln("\x1b[2mNo session selected. Pick a worker to attach its terminal.\x1b[0m");
if (initFailed) {
return (
<div className="grid h-full place-items-center bg-terminal p-4 font-mono text-[12px] text-muted-foreground">
Terminal failed to initialize on this GPU/driver. Restart the app to retry.
</div>
);
}
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(containerRef.current);
return () => {
if (rafId !== undefined) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
detach?.();
terminalRef.current = null;
try {
terminal.dispose();
} catch {
// Some xterm renderer addons can throw during dispose in certain GPU
// environments; the terminal is being torn down regardless.
}
};
}, [session?.id, session?.terminalHandleId, attach]);
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = terminalTheme(theme);
}
}, [theme]);
const banner = bannerText(state, error);
const showEmptyState = !handleId;
return (
<div className="relative h-full min-h-0">
<div ref={containerRef} className="h-full min-h-0 bg-terminal p-3" />
<div className="relative h-full min-h-0 bg-terminal">
<XtermTerminal ariaLabel="Session terminal" onError={handleInitError} onReady={handleReady} theme={theme} />
{showEmptyState && (
<div className="absolute inset-0 grid place-items-center bg-terminal font-mono text-[13px]">
<div className="text-center">
<div className="text-[var(--term-fg)]">Agent Orchestrator</div>
<div className="mt-2 text-[var(--term-dim)]">
No session selected. Pick a worker to attach its terminal.
</div>
</div>
</div>
)}
{banner && (
<div className="absolute inset-x-3 top-2 rounded-md border border-border bg-surface/95 px-3 py-1.5 font-mono text-[11px] text-muted-foreground">
{banner}
@ -175,16 +101,3 @@ function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) {
</div>
);
}
// The terminal is the agent CLI; it keeps the emdash dark palette (green cursor) in
// both themes — see DESIGN.md → Color. The `theme` arg is kept for the signature the
// caller uses on theme change.
function terminalTheme(_theme: Theme) {
return {
background: "#161616",
foreground: "#d7d7d2",
cursor: "#7bd88f",
cursorAccent: "#161616",
selectionBackground: "rgba(63, 142, 247, 0.35)",
};
}

View File

@ -0,0 +1,77 @@
import { useCanGoBack, useRouter, useRouterState } from "@tanstack/react-router";
import { ArrowLeft, ArrowRight, PanelLeft } from "lucide-react";
import { useUiStore } from "../stores/ui-store";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
// macOS-only titlebar cluster (sidebar toggle + history arrows) pinned beside
// the traffic lights, VS Code-style. Approved divergence from the web
// reference, which has no window chrome (DESIGN.md banner, 2026-06-10).
// Rendered once by the shell as a fixed overlay (.titlebar-nav in styles.css)
// so the buttons occupy the exact same spot whether the sidebar is expanded
// or collapsed; the collapsed-rail topbars pad past it (.is-under-titlebar-nav).
export function TitlebarNav() {
const { isSidebarOpen, toggleSidebar } = useUiStore();
const router = useRouter();
const canGoBack = useCanGoBack();
// No useCanGoForward in the installed router; derive it from the history
// index the same way useCanGoBack does (any back/forward/push re-renders
// via the location store, so history.length is read fresh).
const canGoForward = useRouterState({
select: (state) => state.location.state.__TSR_index < router.history.length - 1,
});
if (!isMac) return null;
return (
<div className="titlebar-nav" style={noDragStyle}>
<TitlebarButton
label={isSidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
onClick={toggleSidebar}
title={`${isSidebarOpen ? "Collapse" : "Expand"} sidebar · ⌘B`}
>
<PanelLeft className="h-[15px] w-[15px]" aria-hidden="true" />
</TitlebarButton>
<TitlebarButton disabled={!canGoBack} label="Go back" onClick={() => router.history.back()} title="Go back">
<ArrowLeft className="h-[15px] w-[15px]" aria-hidden="true" />
</TitlebarButton>
<TitlebarButton
disabled={!canGoForward}
label="Go forward"
onClick={() => router.history.forward()}
title="Go forward"
>
<ArrowRight className="h-[15px] w-[15px]" aria-hidden="true" />
</TitlebarButton>
</div>
);
}
function TitlebarButton({
label,
title,
disabled,
onClick,
children,
}: {
label: string;
title: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
aria-label={label}
className="titlebar-nav__btn grid place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-45"
disabled={disabled}
onClick={onClick}
style={noDragStyle}
title={title}
type="button"
>
{children}
</button>
);
}

View File

@ -1,20 +1,20 @@
import {
Columns2,
FileText,
GitPullRequest,
MoreHorizontal,
PanelLeft,
Pin,
Plus,
Terminal,
Waypoints,
} from "lucide-react";
import type { WorkbenchTab, WorkbenchView } from "../stores/ui-store";
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
import { Bell, GitBranch, LayoutGrid, PanelRightClose, PanelRightOpen, Waypoints } from "lucide-react";
import { type WorkbenchView, useUiStore } from "../stores/ui-store";
import type { WorkerDisplayStatus, WorkspaceSession } from "../types/workspace";
import { workerDisplayStatus } from "../types/workspace";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
// Session status → pill tone, mirroring agent-orchestrator's StatusBadge
// (working=orange & breathing, input=amber, fail=red, ready=green, done=neutral).
// Tones are theme vars so the pill tracks the light/dark status palettes.
const STATUS_PILL: Record<WorkerDisplayStatus, { label: string; tone: string; breathe: boolean }> = {
working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
};
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
@ -22,173 +22,107 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti
type TopbarProps = {
view: WorkbenchView;
session?: WorkspaceSession;
workspace?: WorkspaceSummary;
workbenchTab: WorkbenchTab;
onSetWorkbenchTab: (tab: WorkbenchTab) => void;
onNewWorker: () => void;
onToggleSidebar: () => void;
/** Project crumb for orchestrator sessions (matches AO topbar-project-line). */
projectLabel?: string;
/** Back-to-board navigation for the Kanban / Open Kanban button. */
onOpenBoard?: () => void;
};
export function Topbar({
view,
session,
workspace,
workbenchTab,
onSetWorkbenchTab,
onNewWorker,
onToggleSidebar,
}: TopbarProps) {
export function Topbar({ view, session, projectLabel, onOpenBoard }: TopbarProps) {
const isSidebarOpen = useUiStore((state) => state.isSidebarOpen);
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
return (
<header
className="flex h-11 shrink-0 items-center gap-2.5 border-b border-border bg-background px-3"
className={cn("dashboard-app-header session-topbar", isMac && !isSidebarOpen && "is-under-titlebar-nav")}
style={dragStyle}
>
{isMac && <span className="inline-block w-[66px] shrink-0" />}
<button
aria-label="Toggle sidebar"
className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-raised hover:text-muted-foreground"
onClick={onToggleSidebar}
style={noDragStyle}
title="Toggle sidebar (⌘B)"
type="button"
>
<PanelLeft className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
<div className="session-topbar__lead">
{view === "orchestrator" ? (
<div className="flex min-w-0 items-center gap-2 text-[13px] text-muted-foreground">
<Waypoints className="h-[15px] w-[15px] shrink-0 text-accent" aria-hidden="true" />
<span className="truncate font-medium text-foreground">Orchestrator</span>
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">
{projectLabel ?? session?.workspaceName ?? "Project"}
</span>
<span aria-hidden="true" className="topbar-identity-sep">
·
</span>
<span className="session-detail-mode-badge session-detail-mode-badge--neutral">
<Waypoints className="size-3 shrink-0" aria-hidden="true" />
Orchestrator
</span>
</div>
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground">
<span className="truncate">{session?.workspaceName ?? workspace?.name ?? "—"}</span>
<span className="text-passive">/</span>
<span className="truncate font-medium text-foreground">{session?.title ?? "session"}</span>
<Pin className="h-3 w-3 shrink-0 text-passive" aria-hidden="true" />
<div className="session-topbar__identity">
<div className="session-topbar__branch">
<GitBranch className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{session?.branch || `session/${session?.id ?? ""}`}</span>
</div>
{session ? <SessionStatusPill session={session} /> : null}
</div>
)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-0.5">
{view === "orchestrator" ? (
<>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{/* Bell leads the actions row, as in AO's SessionDetailHeader. */}
<button aria-label="Notifications" className="dashboard-app-header__icon-btn" style={noDragStyle} type="button">
<Bell className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
<button
aria-label="New worker"
className="mr-1 inline-flex h-6 items-center gap-1.5 rounded-md border border-border px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:border-accent hover:text-accent"
onClick={onNewWorker}
aria-label={view === "orchestrator" ? "Open Kanban" : "Back to board"}
className="dashboard-app-header__primary-btn"
onClick={onOpenBoard}
style={noDragStyle}
type="button"
>
<Plus className="h-3 w-3" aria-hidden="true" />
New worker
<LayoutGrid className="h-3.5 w-3.5" aria-hidden="true" />
{view === "orchestrator" ? "Open Kanban" : "Kanban"}
</button>
<IconToggle label="Terminal" active>
<Terminal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle label="More">
<MoreHorizontal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
</>
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
{view === "session" && (
<button
aria-label={isInspectorOpen ? "Close inspector panel" : "Open inspector panel"}
aria-pressed={isInspectorOpen}
className="dashboard-app-header__icon-btn"
onClick={toggleInspector}
style={noDragStyle}
title={`${isInspectorOpen ? "Close" : "Open"} inspector · ⌘⇧B`}
type="button"
>
{isInspectorOpen ? (
<PanelRightClose className="h-[15px] w-[15px]" aria-hidden="true" />
) : (
<>
<PrPill session={session} workspace={workspace} />
<IconToggle
label="Changes"
active={workbenchTab === "changes"}
onClick={() => onSetWorkbenchTab("changes")}
>
<Columns2 className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle label="Files" active={workbenchTab === "files"} onClick={() => onSetWorkbenchTab("files")}>
<FileText className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle
label="Terminal"
active={workbenchTab === "terminal"}
onClick={() => onSetWorkbenchTab("terminal")}
>
<Terminal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle label="Session actions">
<MoreHorizontal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
</>
<PanelRightOpen className="h-[15px] w-[15px]" aria-hidden="true" />
)}
</button>
)}
</div>
</header>
);
}
function IconToggle({
label,
active = false,
onClick,
children,
}: {
label: string;
active?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
// StatusBadge --pill: tinted bordered pill (inset 25%-tone hairline + 7%-tone
// fill) with a 6px dot that breathes while the agent is working.
function SessionStatusPill({ session }: { session: WorkspaceSession }) {
const { label, tone, breathe } = STATUS_PILL[workerDisplayStatus(session)];
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={label}
aria-pressed={active}
className={cn(
"grid h-7 w-7 place-items-center rounded-md transition-colors",
active ? "bg-accent-weak text-accent" : "text-passive hover:bg-raised hover:text-muted-foreground",
)}
onClick={onClick}
style={noDragStyle}
type="button"
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold leading-none"
style={{
color: tone,
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
}}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function PrPill({ session, workspace }: { session?: WorkspaceSession; workspace?: WorkspaceSummary }) {
const pr = session?.pullRequest ?? workspace?.pullRequest;
const status = session ? workerDisplayStatus(session) : "working";
if (!pr) {
return (
<button
className="mr-1 inline-flex h-6 items-center gap-1.5 rounded-md border border-border px-2.5 text-[11.5px] font-medium text-muted-foreground transition-colors hover:border-accent hover:text-accent"
style={noDragStyle}
type="button"
>
<GitPullRequest className="h-3 w-3" aria-hidden="true" />
Open PR
</button>
);
}
const tone =
status === "ci_failed"
? "border-error/40 bg-error/10 text-error"
: status === "needs_you"
? "border-warning/40 bg-warning/10 text-warning"
: "border-success/40 bg-success/10 text-success";
const label = status === "ci_failed" ? "CI failed" : status === "needs_you" ? "review requested" : "mergeable";
return (
<button
className={cn(
"mr-1 inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-[11.5px] font-medium",
tone,
)}
style={noDragStyle}
title={`PR #${pr.number}${label}`}
type="button"
>
<GitPullRequest className="h-3 w-3" aria-hidden="true" />
PR #{pr.number} · {label}
</button>
<span
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
style={{ background: tone }}
/>
{label}
</span>
);
}

View File

@ -0,0 +1,203 @@
// Self-contained xterm.js surface, ported from yyork's terminal architecture.
//
// Design rules (the reason this component exists):
// - The mount effect is dependency-free: the terminal instance is created once
// per mount and NEVER torn down because a callback identity or session
// changed. Session switching is the owner's job (re-point the mux, clear the
// screen) — see TerminalPane.
// - Nothing writes into the buffer at mount. Status/empty-state belongs to DOM
// chrome around the terminal, not inside it. Writing before layout settles
// is what crashed xterm's Viewport (`dimensions` of a zero-sized renderer).
// - Fitting runs on several triggers, not one: FitAddon derives the column
// count from measured cell width, and if it measures before the monospace
// font's real metrics are resolved it over-counts columns and the grid
// overflows the panel. So: next frame, two settle timeouts, fonts.ready,
// and a ResizeObserver. xterm itself only fires onResize when the grid
// actually changed, so repeated fits don't spam the PTY.
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import type { AttachableTerminal } from "../hooks/useTerminalSession";
import { buildTerminalThemes } from "../lib/terminal-themes";
import type { Theme } from "../stores/ui-store";
export type XtermTerminalProps = {
ariaLabel?: string;
className?: string;
theme: Theme;
/** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void;
/**
* The terminal is open in the DOM and ready to be attached to a PTY. The
* handle stays valid until unmount; cols/rows are live getters.
*/
onReady?: (terminal: AttachableTerminal) => void;
};
// Prefer the WebGL renderer, fall back to 2D canvas. Both rasterize box-drawing
// glyphs themselves onto a fixed cell grid; the DOM renderer does not, so TUI
// borders would drift. Loaded after open().
function loadRenderer(term: Terminal): void {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
return;
} catch {
// WebGL context unavailable — fall through to the canvas renderer.
}
try {
term.loadAddon(new CanvasAddon());
} catch (error) {
console.warn("xterm: WebGL and canvas renderers unavailable; box-drawing may drift", error);
}
}
// xterm palette tracks the app theme (see lib/terminal-themes.ts + --term-* in
// styles.css). The PTY content is still the agent's own ANSI output.
const terminalThemes = buildTerminalThemes();
// Erase scrollback (3J) + display (2J) and home the cursor — yyork's
// terminalResetSequence. Deliberately NOT term.reset(): a full RIS also wipes
// the DEC private modes zellij enabled when its attach process started (SGR
// mouse tracking, alt screen), and the mux's 50KB ring replay no longer
// contains those init sequences — after a RIS, xterm never re-enters
// mouse-tracking mode, wheel events stop being forwarded to zellij, and
// scrolling goes dead.
const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H";
export function XtermTerminal(props: XtermTerminalProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
// Latest callbacks in a ref so the mount effect stays dependency-free — we
// never tear down and recreate the terminal because a handler identity
// changed between renders.
const callbacksRef = useRef(props);
useEffect(() => {
callbacksRef.current = props;
});
useEffect(() => {
const term = termRef.current;
if (!term) return;
term.options.theme = props.theme === "dark" ? terminalThemes.dark : terminalThemes.light;
}, [props.theme]);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
let term: Terminal;
try {
term = new Terminal({
// Required for the Unicode 11 width addon below.
allowProposedApi: true,
cursorBlink: true,
// Resolve the Nerd Font stack from --font-mono (styles.css) at
// construction so terminal glyphs follow the app's font tokens. The
// box-drawing grid is rasterized by the WebGL/canvas renderer itself,
// but powerline separators and file-type icons are real PUA codepoints
// that must come from a system-installed Nerd Font.
fontFamily:
getComputedStyle(host).getPropertyValue("--font-mono").trim() ||
'ui-monospace, Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
lineHeight: 1.35,
// Agent TUIs leave SGR bold active while using ANSI black for
// separators; keep bold weight-only so black stays black.
drawBoldTextInBrightColors: false,
// Auto-adjust glyph colors that don't clear WCAG AA against their cell
// background, the way VS Code's terminal does; without it dim colors
// render washed out.
minimumContrastRatio: 4.5,
// The mux PTY runs `zellij attach` (backend AttachCommand), a
// full-screen alt-buffer app that owns scrollback itself — same as
// yyork. xterm's own buffer never accumulates history (the alt screen
// doesn't feed scrollback), and wheel events reach zellij as mouse
// reports instead of scrolling locally. 0 also stops FitAddon
// reserving ~14px on the right for a scrollbar that can never appear.
scrollback: 0,
theme: props.theme === "dark" ? terminalThemes.dark : terminalThemes.light,
});
} catch (error) {
callbacksRef.current.onError?.(error);
return undefined;
}
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const unicode = new Unicode11Addon();
term.loadAddon(unicode);
term.unicode.activeVersion = "11";
term.loadAddon(new WebLinksAddon());
term.loadAddon(new SearchAddon());
term.open(host);
loadRenderer(term);
const fitTerminal = () => {
try {
fit.fit();
} catch {
// Container momentarily has no size (hidden/unmounting) — a later
// trigger retries.
}
};
const raf = requestAnimationFrame(fitTerminal);
const settleTimers = [window.setTimeout(fitTerminal, 50), window.setTimeout(fitTerminal, 250)];
if (document.fonts?.ready) {
void document.fonts.ready.then(fitTerminal);
}
const observer = new ResizeObserver(fitTerminal);
observer.observe(host);
// Live cols/rows getters: the owner reads the current grid at attach time,
// not a snapshot taken at ready time (the first fit may not have run yet).
const handle: AttachableTerminal = {
get cols() {
return term.cols;
},
get rows() {
return term.rows;
},
write: (data) => term.write(data),
writeln: (line) => term.writeln(line),
clear: () => term.write(CLEAR_SEQUENCE),
onData: (listener) => term.onData(listener),
onResize: (listener) => term.onResize(listener),
};
callbacksRef.current.onReady?.(handle);
return () => {
termRef.current = null;
cancelAnimationFrame(raf);
for (const timer of settleTimers) window.clearTimeout(timer);
observer.disconnect();
try {
term.dispose();
} catch {
// Some renderer addons can throw during dispose in certain GPU
// environments; the terminal is being torn down regardless.
}
};
}, []);
return (
<div
ref={hostRef}
aria-label={props.ariaLabel}
className={props.className}
style={{ height: "100%", overflow: "hidden", width: "100%" }}
/>
);
}

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@ -0,0 +1,74 @@
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
export function DropdownMenuContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[10rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-[var(--shadow)]",
"data-[state=open]:animate-overlay-in",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
export function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center gap-2.5 rounded-md px-2 py-1.5 text-[13px] outline-none transition-colors",
"text-muted-foreground focus:bg-surface focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:size-[15px] [&_svg]:shrink-0 [&_svg]:text-passive",
inset && "pl-8",
className,
)}
{...props}
/>
);
}
export function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.Label
className={cn(
"px-2 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-passive",
inset && "pl-8",
className,
)}
{...props}
/>
);
}
export function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return <DropdownMenuPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />;
}
export function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return <span className={cn("ml-auto font-mono text-[10px] tracking-[0.08em] text-passive", className)} {...props} />;
}

View File

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,45 @@
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn("flex h-full w-full aria-[orientation=vertical]:flex-col", className)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };

View File

@ -0,0 +1,160 @@
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,107 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };

View File

@ -0,0 +1,677 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { Slot } from "radix-ui";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn("flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", className)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-accent", className)} {...props} />;
}
export { Skeleton };

View File

@ -0,0 +1,76 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@ -0,0 +1,76 @@
import { useCallback, useEffect, useRef } from "react";
interface UseResizableOptions {
/** CSS custom property to drive (set on :root), e.g. "--ao-sidebar-w". */
cssVar: string;
/** localStorage key to persist the width. */
storageKey: string;
defaultWidth: number;
min: number;
max: number;
/**
* Which edge the drag handle sits on relative to the panel it resizes.
* "right" (sidebar handle) grows with rightward drag; "left" (inspector
* handle) grows with leftward drag.
*/
edge: "left" | "right";
}
/**
* Pointer-driven panel resize, cloned from agent-orchestrator's useResizable.
* Persists the width to localStorage and applies it via a CSS custom property
* on :root (so the consuming layout reads it with `width: var(--cssVar, default)`),
* avoiding any inline `style=`.
*/
export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge }: UseResizableOptions) {
const widthRef = useRef(defaultWidth);
const apply = useCallback(
(next: number) => {
const clamped = Math.min(max, Math.max(min, next));
widthRef.current = clamped;
document.documentElement.style.setProperty(cssVar, `${clamped}px`);
},
[cssVar, max, min],
);
// Restore persisted width on mount.
useEffect(() => {
const saved = Number(window.localStorage.getItem(storageKey));
apply(Number.isFinite(saved) && saved > 0 ? saved : defaultWidth);
return () => {
document.documentElement.style.removeProperty(cssVar);
};
}, [apply, cssVar, defaultWidth, storageKey]);
const onPointerDown = useCallback(
(event: React.PointerEvent<HTMLElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = widthRef.current;
const sign = edge === "right" ? 1 : -1;
document.body.classList.add("is-resizing-x");
const onMove = (e: PointerEvent) => {
apply(startWidth + sign * (e.clientX - startX));
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
document.body.classList.remove("is-resizing-x");
window.localStorage.setItem(storageKey, String(widthRef.current));
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[apply, edge, storageKey],
);
/** Double-click the handle to reset to the default width. */
const onDoubleClick = useCallback(() => {
apply(defaultWidth);
window.localStorage.setItem(storageKey, String(defaultWidth));
}, [apply, defaultWidth, storageKey]);
return { onPointerDown, onDoubleClick };
}

View File

@ -167,9 +167,20 @@ describe("useTerminalSession", () => {
terminal.typeKeys("ls\r");
expect(muxes[0].inputs).toEqual([["handle-1", "ls\r"]]);
terminal.emitResize(120, 40);
act(() => void vi.advanceTimersByTime(100));
expect(muxes[0].resizes).toContainEqual(["handle-1", 120, 40]);
});
it("collapses a drag's burst of grid changes into one trailing PTY resize", () => {
const { terminal, muxes } = setup();
const initialResizes = muxes[0].resizes.length; // connect() sends the opening size
terminal.emitResize(100, 30);
terminal.emitResize(110, 34);
terminal.emitResize(120, 40);
act(() => void vi.advanceTimersByTime(100));
expect(muxes[0].resizes.slice(initialResizes)).toEqual([["handle-1", 120, 40]]);
});
it("marks exit in the terminal and refetches workspace state instead of writing status", () => {
const { view, terminal, muxes, invalidateSpy } = setup();
act(() => muxes[0].emitExit("handle-1"));

View File

@ -24,8 +24,10 @@ export type AttachableTerminal = {
write: (data: Uint8Array) => void;
writeln: (line: string) => void;
/**
* Erase screen + scrollback while preserving terminal modes. A full reset
* drops zellij mouse tracking, so wheel scroll stops after reconnect.
* Erase screen + scrollback and home the cursor, preserving terminal modes.
* Never a full reset (RIS): that would drop the mouse-tracking mode zellij
* enabled at attach, which the ring replay cannot re-establish killing
* wheel scroll (see XtermTerminal's CLEAR_SEQUENCE).
*/
clear: () => void;
onData: (listener: (data: string) => void) => { dispose: () => void };
@ -49,6 +51,10 @@ export type UseTerminalSessionOptions = {
const RETRY_BASE_MS = 500;
const RETRY_MAX_MS = 8_000;
// Trailing debounce on grid changes: a pane drag emits a burst of intermediate
// sizes; the attached program should get one SIGWINCH when the drag settles,
// not dozens (yyork's terminal-panel does the same at its socket layer).
const RESIZE_DEBOUNCE_MS = 100;
function defaultCreateMux(): TerminalMux {
// Resolved per connect, not per hook: a daemon restart can change the port.
@ -73,6 +79,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
handle: null as string | null,
disposers: [] as Array<() => void>,
retryTimer: null as ReturnType<typeof setTimeout> | null,
resizeTimer: null as ReturnType<typeof setTimeout> | null,
attempts: 0,
firstAttach: true,
detached: true,
@ -93,6 +100,10 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
clearTimeout(r.retryTimer);
r.retryTimer = null;
}
if (r.resizeTimer) {
clearTimeout(r.resizeTimer);
r.resizeTimer = null;
}
r.disposers.forEach((dispose) => dispose());
r.disposers = [];
r.mux?.dispose();
@ -148,18 +159,26 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
}),
);
const input = terminal.onData((data) => mux.sendInput(handle, data));
const resize = terminal.onResize(({ cols, rows }) => mux.resize(handle, cols, rows));
// xterm only fires onResize when the grid actually changed; the debounce
// additionally collapses a drag's burst of changes into one PTY resize.
const resize = terminal.onResize(({ cols, rows }) => {
if (r.resizeTimer) clearTimeout(r.resizeTimer);
r.resizeTimer = setTimeout(() => {
r.resizeTimer = null;
mux.resize(handle, cols, rows);
}, RESIZE_DEBOUNCE_MS);
});
r.disposers.push(
() => input.dispose(),
() => resize.dispose(),
);
if (r.firstAttach) {
terminal.writeln(`\x1b[2mAttaching to ${sessionRef.current?.title ?? handle}\x1b[0m`);
} else {
// The server replays the recent-output ring on open (backend
// internal/terminal/ring.go); clear the stale screen so it isn't doubled.
// Screen-clear only, never reset(): RIS wipes zellij's mouse mode.
// Connection status is chrome (the pane's banner), never buffer content —
// the PTY owns the buffer. On reattach the server replays the recent-output
// ring (backend internal/terminal/ring.go); clear the stale screen so it
// isn't doubled. Screen-clear only, never reset(): RIS would wipe zellij's
// mouse-tracking mode and freeze wheel scrolling after every reconnect.
if (!r.firstAttach) {
terminal.clear();
}
r.firstAttach = false;

View File

@ -29,18 +29,24 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
workspaceName: project.name,
title: session.displayName ?? session.issueId ?? session.id,
provider: toAgentProvider(session.harness),
branch: "",
kind: session.kind === "orchestrator" ? "orchestrator" : session.kind === "worker" ? "worker" : undefined,
branch: `session/${session.id}`,
status: toSessionStatus(session.status, session.isTerminated),
updatedAt: new Date(session.updatedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
})),
}));
}
export function useWorkspaceQuery() {
return useQuery({
// Shared so route loaders can prefetch via queryClient.ensureQueryData (paired
// with the router's defaultPreload: "intent") and the hook reads the same cache.
export const workspaceQueryOptions = {
queryKey: workspaceQueryKey,
queryFn: fetchWorkspaces,
retry: 1,
refetchInterval: 15_000,
});
};
export function useWorkspaceQuery() {
return useQuery(workspaceQueryOptions);
}

View File

@ -72,3 +72,20 @@ export const apiClient = createClient<paths>({
baseUrl: initialApiBaseUrl,
fetch: runtimeFetch,
});
/**
* Human-readable message from an openapi-fetch `error` value. The daemon's
* error body is `{ error, code, message, requestId }` (backend apierr) a
* plain object, so `String(error)` renders "[object Object]". Falls back
* through Error instances and strings.
*/
export function apiErrorMessage(error: unknown, fallback = "Request failed"): string {
if (error instanceof Error) return error.message;
if (typeof error === "string" && error !== "") return error;
if (typeof error === "object" && error !== null) {
const body = error as { message?: unknown; error?: unknown };
if (typeof body.message === "string" && body.message !== "") return body.message;
if (typeof body.error === "string" && body.error !== "") return body.error;
}
return fallback;
}

View File

@ -0,0 +1,14 @@
/** Compact relative time — ported from agent-orchestrator session-detail-utils. */
export function formatTimeCompact(isoDate: string | null | undefined): string {
if (!isoDate) return "just now";
const ts = new Date(isoDate).getTime();
if (!Number.isFinite(ts)) return "just now";
const diffMs = Date.now() - ts;
if (diffMs <= 0) return "just now";
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${Math.floor(diffHours / 24)}d ago`;
}

View File

@ -15,7 +15,8 @@ export const mockWorkspaces: WorkspaceSummary[] = [
provider: "claude-code",
branch: "feat/refactor-mux",
status: "working",
updatedAt: "now",
updatedAt: new Date().toISOString(),
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
changedFiles: [
{
path: "internal/mux/terminal_mux.go",
@ -40,7 +41,8 @@ export const mockWorkspaces: WorkspaceSummary[] = [
provider: "codex",
branch: "fix/webgl-fallback",
status: "needs_input",
updatedAt: "now",
updatedAt: new Date().toISOString(),
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
},
],
},

View File

@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
import type { useDaemonStatus } from "../hooks/useDaemonStatus";
import type { AgentProvider } from "../types/workspace";
// Shared state the persistent _shell layout owns and route content reads. The
// daemon status effect (IPC poll + event transport) must run exactly once, so
// it lives in the shell and is handed down here rather than re-run per route.
export type ShellContextValue = {
daemonStatus: ReturnType<typeof useDaemonStatus>;
/** Open the spawn-worker modal, optionally pre-selecting a project. */
openSpawn: (projectId?: string) => void;
createProject: (input: { path: string }) => Promise<void>;
createTask: (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => Promise<void>;
};
const ShellContext = createContext<ShellContextValue | null>(null);
export const ShellProvider = ShellContext.Provider;
export function useShell(): ShellContextValue {
const ctx = useContext(ShellContext);
if (!ctx) throw new Error("useShell must be used within the _shell layout route");
return ctx;
}

View File

@ -0,0 +1,18 @@
import { apiClient } from "./api-client";
/** Spawn the project's orchestrator session via the daemon API. */
export async function spawnOrchestrator(projectId: string): Promise<string> {
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
body: { projectId },
});
if (error || !data?.orchestrator?.id) {
const message =
error && typeof error === "object" && "message" in error && typeof error.message === "string"
? error.message
: `Failed to spawn orchestrator (${response.status})`;
throw new Error(message);
}
return data.orchestrator.id;
}

View File

@ -0,0 +1,62 @@
import type { ITheme } from "@xterm/xterm";
/** xterm palettes harmonized to styles.css tokens (agent-orchestrator pattern). */
export function buildTerminalThemes(): { dark: ITheme; light: ITheme } {
const accent = {
cursor: "#f59f4c",
selDark: "rgba(77, 141, 255, 0.30)",
selLight: "rgba(37, 99, 235, 0.25)",
};
const dark: ITheme = {
background: "#15171b",
foreground: "#d7d7d2",
cursor: accent.cursor,
cursorAccent: "#15171b",
selectionBackground: accent.selDark,
selectionInactiveBackground: "rgba(128, 128, 128, 0.2)",
black: "#15171b",
red: "#ef6b6b",
green: "#74b98a",
yellow: "#e8c14a",
blue: "#4d8dff",
magenta: "#a78bfa",
cyan: "#6fb3c9",
white: "#d7d7d2",
brightBlack: "#7c7c7c",
brightRed: "#ff8a8a",
brightGreen: "#8fd6a6",
brightYellow: "#f0d06b",
brightBlue: "#7eaaff",
brightMagenta: "#c4b0fc",
brightCyan: "#8fcfe0",
brightWhite: "#f4f5f7",
};
const light: ITheme = {
background: "#fafafa",
foreground: "#24292f",
cursor: accent.cursor,
cursorAccent: "#fafafa",
selectionBackground: accent.selLight,
selectionInactiveBackground: "rgba(128, 128, 128, 0.15)",
black: "#24292f",
red: "#b42318",
green: "#1a7f37",
yellow: "#9a6b00",
blue: "#2563eb",
magenta: "#8e24aa",
cyan: "#0b7285",
white: "#4b5563",
brightBlack: "#374151",
brightRed: "#912018",
brightGreen: "#176639",
brightYellow: "#6f4a00",
brightBlue: "#1d4ed8",
brightMagenta: "#7b1fa2",
brightCyan: "#155e75",
brightWhite: "#374151",
};
return { dark, light };
}

View File

@ -9,85 +9,224 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as WorkspacesWorkspaceIdRouteImport } from "./routes/workspaces.$workspaceId";
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from "./routes/workspaces.$workspaceId_.sessions.$sessionId";
import { Route as ShellRouteImport } from "./routes/_shell";
import { Route as ShellIndexRouteImport } from "./routes/_shell.index";
import { Route as ShellReviewsRouteImport } from "./routes/_shell.reviews";
import { Route as ShellReviewRouteImport } from "./routes/_shell.review";
import { Route as ShellPrsRouteImport } from "./routes/_shell.prs";
import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId";
import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId";
import { Route as ShellProjectsProjectIdSettingsRouteImport } from "./routes/_shell.projects.$projectId_.settings";
import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from "./routes/_shell.projects.$projectId_.sessions.$sessionId";
const IndexRoute = IndexRouteImport.update({
const ShellRoute = ShellRouteImport.update({
id: "/_shell",
getParentRoute: () => rootRouteImport,
} as any);
const ShellIndexRoute = ShellIndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
getParentRoute: () => ShellRoute,
} as any);
const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
id: "/workspaces/$workspaceId",
path: "/workspaces/$workspaceId",
getParentRoute: () => rootRouteImport,
const ShellReviewsRoute = ShellReviewsRouteImport.update({
id: "/reviews",
path: "/reviews",
getParentRoute: () => ShellRoute,
} as any);
const WorkspacesWorkspaceIdSessionsSessionIdRoute = WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({
id: "/workspaces/$workspaceId_/sessions/$sessionId",
path: "/workspaces/$workspaceId/sessions/$sessionId",
getParentRoute: () => rootRouteImport,
const ShellReviewRoute = ShellReviewRouteImport.update({
id: "/review",
path: "/review",
getParentRoute: () => ShellRoute,
} as any);
const ShellPrsRoute = ShellPrsRouteImport.update({
id: "/prs",
path: "/prs",
getParentRoute: () => ShellRoute,
} as any);
const ShellSessionsSessionIdRoute = ShellSessionsSessionIdRouteImport.update({
id: "/sessions/$sessionId",
path: "/sessions/$sessionId",
getParentRoute: () => ShellRoute,
} as any);
const ShellProjectsProjectIdRoute = ShellProjectsProjectIdRouteImport.update({
id: "/projects/$projectId",
path: "/projects/$projectId",
getParentRoute: () => ShellRoute,
} as any);
const ShellProjectsProjectIdSettingsRoute = ShellProjectsProjectIdSettingsRouteImport.update({
id: "/projects/$projectId_/settings",
path: "/projects/$projectId/settings",
getParentRoute: () => ShellRoute,
} as any);
const ShellProjectsProjectIdSessionsSessionIdRoute = ShellProjectsProjectIdSessionsSessionIdRouteImport.update({
id: "/projects/$projectId_/sessions/$sessionId",
path: "/projects/$projectId/sessions/$sessionId",
getParentRoute: () => ShellRoute,
} as any);
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
"/": typeof ShellIndexRoute;
"/prs": typeof ShellPrsRoute;
"/review": typeof ShellReviewRoute;
"/reviews": typeof ShellReviewsRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute;
"/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
"/prs": typeof ShellPrsRoute;
"/review": typeof ShellReviewRoute;
"/reviews": typeof ShellReviewsRoute;
"/": typeof ShellIndexRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute;
"/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
"/workspaces/$workspaceId_/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
"/_shell": typeof ShellRouteWithChildren;
"/_shell/prs": typeof ShellPrsRoute;
"/_shell/review": typeof ShellReviewRoute;
"/_shell/reviews": typeof ShellReviewsRoute;
"/_shell/": typeof ShellIndexRoute;
"/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/_shell/projects/$projectId_/settings": typeof ShellProjectsProjectIdSettingsRoute;
"/_shell/projects/$projectId_/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId";
fullPaths:
| "/"
| "/prs"
| "/review"
| "/reviews"
| "/projects/$projectId"
| "/sessions/$sessionId"
| "/projects/$projectId/settings"
| "/projects/$projectId/sessions/$sessionId";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId";
id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId";
to:
| "/prs"
| "/review"
| "/reviews"
| "/"
| "/projects/$projectId"
| "/sessions/$sessionId"
| "/projects/$projectId/settings"
| "/projects/$projectId/sessions/$sessionId";
id:
| "__root__"
| "/_shell"
| "/_shell/prs"
| "/_shell/review"
| "/_shell/reviews"
| "/_shell/"
| "/_shell/projects/$projectId"
| "/_shell/sessions/$sessionId"
| "/_shell/projects/$projectId_/settings"
| "/_shell/projects/$projectId_/sessions/$sessionId";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute;
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
ShellRoute: typeof ShellRouteWithChildren;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/";
"/_shell": {
id: "/_shell";
path: "";
fullPath: "/";
preLoaderRoute: typeof ShellRouteImport;
parentRoute: typeof rootRouteImport;
};
"/_shell/": {
id: "/_shell/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
preLoaderRoute: typeof ShellIndexRouteImport;
parentRoute: typeof ShellRoute;
};
"/workspaces/$workspaceId": {
id: "/workspaces/$workspaceId";
path: "/workspaces/$workspaceId";
fullPath: "/workspaces/$workspaceId";
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport;
parentRoute: typeof rootRouteImport;
"/_shell/reviews": {
id: "/_shell/reviews";
path: "/reviews";
fullPath: "/reviews";
preLoaderRoute: typeof ShellReviewsRouteImport;
parentRoute: typeof ShellRoute;
};
"/workspaces/$workspaceId_/sessions/$sessionId": {
id: "/workspaces/$workspaceId_/sessions/$sessionId";
path: "/workspaces/$workspaceId/sessions/$sessionId";
fullPath: "/workspaces/$workspaceId/sessions/$sessionId";
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport;
parentRoute: typeof rootRouteImport;
"/_shell/review": {
id: "/_shell/review";
path: "/review";
fullPath: "/review";
preLoaderRoute: typeof ShellReviewRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/prs": {
id: "/_shell/prs";
path: "/prs";
fullPath: "/prs";
preLoaderRoute: typeof ShellPrsRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/sessions/$sessionId": {
id: "/_shell/sessions/$sessionId";
path: "/sessions/$sessionId";
fullPath: "/sessions/$sessionId";
preLoaderRoute: typeof ShellSessionsSessionIdRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/projects/$projectId": {
id: "/_shell/projects/$projectId";
path: "/projects/$projectId";
fullPath: "/projects/$projectId";
preLoaderRoute: typeof ShellProjectsProjectIdRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/projects/$projectId_/settings": {
id: "/_shell/projects/$projectId_/settings";
path: "/projects/$projectId/settings";
fullPath: "/projects/$projectId/settings";
preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/projects/$projectId_/sessions/$sessionId": {
id: "/_shell/projects/$projectId_/sessions/$sessionId";
path: "/projects/$projectId/sessions/$sessionId";
fullPath: "/projects/$projectId/sessions/$sessionId";
preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport;
parentRoute: typeof ShellRoute;
};
}
}
interface ShellRouteChildren {
ShellPrsRoute: typeof ShellPrsRoute;
ShellReviewRoute: typeof ShellReviewRoute;
ShellReviewsRoute: typeof ShellReviewsRoute;
ShellIndexRoute: typeof ShellIndexRoute;
ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute;
ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute;
ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute;
ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute;
}
const ShellRouteChildren: ShellRouteChildren = {
ShellPrsRoute: ShellPrsRoute,
ShellReviewRoute: ShellReviewRoute,
ShellReviewsRoute: ShellReviewsRoute,
ShellIndexRoute: ShellIndexRoute,
ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute,
ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute,
ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute,
ShellProjectsProjectIdSessionsSessionIdRoute: ShellProjectsProjectIdSessionsSessionIdRoute,
};
const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren);
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRoute,
ShellRoute: ShellRouteWithChildren,
};
export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();

View File

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SessionsBoard } from "../components/SessionsBoard";
export const Route = createFileRoute("/_shell/")({
component: () => <SessionsBoard />,
});

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { SessionsBoard } from "../components/SessionsBoard";
export const Route = createFileRoute("/_shell/projects/$projectId")({
component: ProjectBoardRoute,
});
function ProjectBoardRoute() {
const { projectId } = Route.useParams();
return <SessionsBoard projectId={projectId} />;
}

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { SessionView } from "../components/SessionView";
export const Route = createFileRoute("/_shell/projects/$projectId_/sessions/$sessionId")({
component: ProjectSessionRoute,
});
function ProjectSessionRoute() {
const { projectId, sessionId } = Route.useParams();
return <SessionView projectId={projectId} sessionId={sessionId} />;
}

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { ProjectSettingsForm } from "../components/ProjectSettingsForm";
export const Route = createFileRoute("/_shell/projects/$projectId_/settings")({
component: ProjectSettingsRoute,
});
function ProjectSettingsRoute() {
const { projectId } = Route.useParams();
return <ProjectSettingsForm projectId={projectId} />;
}

View File

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { PullRequestsPage } from "../components/PullRequestsPage";
export const Route = createFileRoute("/_shell/prs")({
component: PullRequestsPage,
});

View File

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ReviewDashboard } from "../components/ReviewDashboard";
export const Route = createFileRoute("/_shell/review")({
component: ReviewDashboard,
});

View File

@ -0,0 +1,8 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
// /reviews is an alias for /review (matches agent-orchestrator).
export const Route = createFileRoute("/_shell/reviews")({
beforeLoad: () => {
throw redirect({ to: "/review" });
},
});

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { SessionView } from "../components/SessionView";
export const Route = createFileRoute("/_shell/sessions/$sessionId")({
component: SessionRoute,
});
function SessionRoute() {
const { sessionId } = Route.useParams();
return <SessionView sessionId={sessionId} />;
}

View File

@ -0,0 +1,194 @@
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { Sidebar } from "../components/Sidebar";
import { SidebarProvider } from "../components/ui/sidebar";
import { SpawnWorkerModal } from "../components/SpawnWorkerModal";
import { TitlebarNav } from "../components/TitlebarNav";
import { useDaemonStatus } from "../hooks/useDaemonStatus";
import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { ShellProvider } from "../lib/shell-context";
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
import { toAgentProvider, toSessionStatus, type AgentProvider, type WorkspaceSummary } from "../types/workspace";
export const Route = createFileRoute("/_shell")({
// Prefetch the workspace list for the whole shell (parent loaders run before
// children); pairs with the router's defaultPreload: "intent" so a hovered
// nav target is warm before the click.
loader: ({ context }) => context.queryClient.ensureQueryData(workspaceQueryOptions),
component: ShellLayout,
});
function systemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Could not load projects";
}
// Persistent app shell: the Sidebar + shared state survive route changes; only
// the <Outlet> content (board / session / settings / …) swaps. Lifted out of
// the old single <App>, with selection now owned by the router (route params)
// instead of Zustand. The daemon-status effect runs here exactly once.
function ShellLayout() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const daemonStatus = useDaemonStatus(queryClient);
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
const [spawnOpen, setSpawnOpen] = useState(false);
const [spawnProjectId, setSpawnProjectId] = useState<string | undefined>(undefined);
const openSpawn = useCallback((projectId?: string) => {
setSpawnProjectId(projectId);
setSpawnOpen(true);
}, []);
const updateWorkspaces = useCallback(
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
queryClient.setQueryData<WorkspaceSummary[]>(workspaceQueryKey, (current = []) => updater(current));
},
[queryClient],
);
const createProject = useCallback(
async (input: { path: string }) => {
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
if (error) throw new Error(apiErrorMessage(error));
if (!data?.project) throw new Error("Project creation returned no project");
const workspace: WorkspaceSummary = {
id: data.project.id,
name: data.project.name,
path: data.project.path,
type: "main",
sessions: [],
};
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
},
[navigate, updateWorkspaces],
);
const createTask = useCallback(
async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => {
const { data, error } = await apiClient.POST("/api/v1/sessions", {
body: {
projectId: input.projectId,
kind: "worker",
harness: input.harness,
prompt: input.prompt,
branch: input.branch || undefined,
},
});
if (error || !data?.session) throw new Error(error ? apiErrorMessage(error) : "No session returned");
const session = data.session;
updateWorkspaces((current) =>
current.map((item) =>
item.id === input.projectId
? {
...item,
sessions: [
{
id: session.id,
terminalHandleId: session.terminalHandleId,
workspaceId: item.id,
workspaceName: item.name,
title: input.prompt,
provider: toAgentProvider(session.harness),
branch: input.branch ?? "",
status: toSessionStatus(session.status, session.isTerminated),
updatedAt: "now",
},
...item.sessions.filter((existing) => existing.id !== session.id),
],
}
: item,
),
);
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId: input.projectId, sessionId: session.id },
});
},
[navigate, updateWorkspaces],
);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}, [theme]);
// Follow OS appearance only until the user picks a theme explicitly.
useEffect(() => {
if (readStoredTheme()) return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: light)");
const handleChange = () => setTheme(systemTheme());
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [setTheme]);
// ⌘B lives in SidebarProvider (shadcn's built-in shortcut), which routes
// through onOpenChange back into the ui-store.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && /^[1-9]$/.test(event.key)) {
const workspace = workspaces[Number(event.key) - 1];
if (workspace) {
event.preventDefault();
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [navigate, workspaces]);
return (
<ShellProvider value={{ daemonStatus, openSpawn, createProject, createTask }}>
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which call
the store directly) stay in sync. --sidebar-width chains to the
drag-resizable --ao-sidebar-w set on :root by useResizable. */}
<SidebarProvider
className="h-screen min-h-0 bg-background text-foreground"
onOpenChange={(open) => open !== isSidebarOpen && toggleSidebar()}
open={isSidebarOpen}
style={
{ "--sidebar-width": "var(--ao-sidebar-w, 240px)", "--sidebar-width-icon": "48px" } as React.CSSProperties
}
>
<Sidebar
daemonStatus={daemonStatus}
onCreateProject={createProject}
onNewWorker={openSpawn}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
workspaces={workspaces}
/>
<main className="flex min-w-0 flex-1 flex-col">
<Outlet />
</main>
{/* Fixed macOS titlebar cluster beside the traffic lights rendered
once here so the toggle/history buttons never move when the
sidebar collapses or expands. MUST come after the sidebar/topbars
in the DOM: Electron builds the window-drag region in document
order (drag rects add, no-drag rects subtract), so the cluster's
no-drag holes only survive if they're processed after the drag
strips they overlap. Rendered first, real clicks get swallowed by
window-drag even though DOM hit-testing looks correct. */}
<TitlebarNav />
</SidebarProvider>
<SpawnWorkerModal
defaultProjectId={spawnProjectId}
onCreateTask={createTask}
onOpenChange={setSpawnOpen}
open={spawnOpen}
workspaces={workspaces}
/>
</ShellProvider>
);
}

View File

@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { App } from "../App";
export const Route = createFileRoute("/")({
component: App,
});

View File

@ -1,11 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { App } from "../App";
export const Route = createFileRoute("/workspaces/$workspaceId")({
component: WorkspaceRoute,
});
function WorkspaceRoute() {
const { workspaceId } = Route.useParams();
return <App routeWorkspaceId={workspaceId} />;
}

View File

@ -1,11 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { App } from "../App";
export const Route = createFileRoute("/workspaces/$workspaceId_/sessions/$sessionId")({
component: SessionRoute,
});
function SessionRoute() {
const { workspaceId, sessionId } = Route.useParams();
return <App routeWorkspaceId={workspaceId} routeSessionId={sessionId} />;
}

View File

@ -1,27 +1,30 @@
import { create } from "zustand";
export type Theme = "light" | "dark";
/** Orchestrator-led: the app lands on the orchestrator; a worker row drills in. */
/** Whether a terminal pane shows the orchestrator or a worker session. */
export type WorkbenchView = "orchestrator" | "session";
/** Worker topbar view toggles — Changes (Git rail) is the default. */
/** Worker detail view toggles — Changes (Git rail) is the default. */
export type WorkbenchTab = "changes" | "files" | "terminal";
// Selection (which project/session is open) now lives in the URL — the router
// is the single source of truth, read via route params. This store holds only
// ephemeral, route-independent UI: theme, sidebar/inspector collapse, and the
// active workbench tab within a session.
type UiState = {
view: WorkbenchView;
workbenchTab: WorkbenchTab;
isSidebarOpen: boolean;
selectedSessionId: string | null;
selectedWorkspaceId: string | null;
isInspectorOpen: boolean;
theme: Theme;
setWorkbenchTab: (tab: WorkbenchTab) => void;
setSystemTheme: (theme: Theme) => void;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
toggleSidebar: () => void;
selectOrchestrator: () => void;
selectWorkspace: (workspaceId: string) => void;
selectSession: (sessionId: string, workspaceId?: string) => void;
toggleInspector: () => void;
};
const sidebarStorageKey = "ao.sidebar.open";
const inspectorStorageKey = "ao.inspector.open";
const themeStorageKey = "ao.theme";
function getLocalStorage() {
if (typeof window === "undefined" || !window.localStorage) return null;
@ -32,34 +35,52 @@ function initialSidebarOpen() {
return getLocalStorage()?.getItem(sidebarStorageKey) !== "false";
}
function initialTheme(): Theme {
if (typeof window === "undefined") return "dark";
function initialInspectorOpen() {
return getLocalStorage()?.getItem(inspectorStorageKey) !== "false";
}
function systemTheme(): Theme {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function initialTheme(): Theme {
const stored = getLocalStorage()?.getItem(themeStorageKey);
if (stored === "light" || stored === "dark") return stored;
return systemTheme();
}
export function readStoredTheme(): Theme | null {
const stored = getLocalStorage()?.getItem(themeStorageKey);
return stored === "light" || stored === "dark" ? stored : null;
}
export const useUiStore = create<UiState>((set) => ({
view: "orchestrator",
workbenchTab: "changes",
isSidebarOpen: initialSidebarOpen(),
selectedSessionId: null,
selectedWorkspaceId: null,
isInspectorOpen: initialInspectorOpen(),
theme: initialTheme(),
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
setSystemTheme: (theme) => set({ theme }),
setTheme: (theme) => {
getLocalStorage()?.setItem(themeStorageKey, theme);
set({ theme });
},
toggleTheme: () =>
set((state) => {
const theme = state.theme === "dark" ? "light" : "dark";
getLocalStorage()?.setItem(themeStorageKey, theme);
return { theme };
}),
toggleSidebar: () =>
set((state) => {
const isSidebarOpen = !state.isSidebarOpen;
getLocalStorage()?.setItem(sidebarStorageKey, String(isSidebarOpen));
return { isSidebarOpen };
}),
selectOrchestrator: () => set({ view: "orchestrator" }),
selectWorkspace: (selectedWorkspaceId) => set({ selectedWorkspaceId }),
selectSession: (selectedSessionId, workspaceId) =>
set((state) => ({
selectedSessionId,
selectedWorkspaceId: workspaceId ?? state.selectedWorkspaceId,
view: "session",
workbenchTab: "changes",
})),
toggleInspector: () =>
set((state) => {
const isInspectorOpen = !state.isInspectorOpen;
getLocalStorage()?.setItem(inspectorStorageKey, String(isInspectorOpen));
return { isInspectorOpen };
}),
}));

View File

@ -10,19 +10,37 @@
--font-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Helvetica Neue",
sans-serif;
--font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* Nerd Font stack (ported from yyork): the terminal reads --font-mono so
* agent TUIs get powerline separators and file-type icons. These are
* system-installed fonts the app bundles none and the browser picks the
* first one present, falling back to plain monospace (no icon glyphs) if a
* user has no Nerd Font. JetBrainsMono Nerd Font is the primary target. */
--font-mono:
"JetBrainsMono Nerd Font Mono", "JetBrainsMono Nerd Font", "FiraCode Nerd Font Mono", "FiraCode Nerd Font",
"MesloLGS NF", "CaskaydiaCove Nerd Font Mono", "CaskaydiaCove Nerd Font", "Hack Nerd Font Mono", "Hack Nerd Font",
"Symbols Nerd Font Mono", "Symbols Nerd Font", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
/* Surfaces */
--color-background: var(--bg);
--color-foreground: var(--fg);
--color-surface: var(--bg-1);
--color-card: var(--bg-1);
--color-card-foreground: var(--fg);
--color-raised: var(--bg-2);
--color-overlay: var(--bg-3);
--color-popover: var(--bg-1);
--color-popover-foreground: var(--fg);
--color-sidebar: var(--bg);
/* shadcn input border uses border-input; map to our hairline border */
--color-input: var(--border);
--color-sidebar: var(--sidebar-bg);
--color-sidebar-foreground: var(--fg-muted);
--color-sidebar-primary: var(--accent);
--color-sidebar-primary-foreground: var(--accent-fg);
--color-sidebar-accent: var(--bg-2);
--color-sidebar-accent-foreground: var(--fg);
--color-sidebar-border: var(--border);
--color-sidebar-ring: var(--accent);
--color-terminal: var(--term-bg);
/* Text */
@ -45,12 +63,22 @@
--color-border: var(--border);
--color-border-strong: var(--border-1);
/* Status — map 1:1 to the daemon's derived statuses */
/* Status map 1:1 to the daemon's derived statuses (agent-orchestrator
* palette): working=orange, needs-you=amber, mergeable=green, fail=red. */
--color-working: var(--orange);
--color-success: var(--green);
--color-success-strong: var(--green-strong);
--color-warning: var(--amber);
--color-error: var(--red);
/* shadcn "destructive" → our error red (Button destructive, Alert, etc.) */
--color-destructive: var(--red);
--color-destructive-foreground: #ffffff;
/* Theme-adaptive interactive surfaces (sidebar hovers, tabs, kanban chrome) */
--color-interactive-hover: var(--interactive-hover);
--color-interactive-active: var(--interactive-active);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
@ -62,31 +90,41 @@
}
:root {
/* Dark — primary. sRGB approximations of emdash's display-p3 neutral ramp. */
--bg: #111111;
--bg-1: #191919;
--bg-2: #222222;
--bg-3: #2a2a2a;
--fg: #eeeeee;
--fg-muted: #b4b4b4;
--fg-passive: #6e6e6e;
--border: #3a3a3a;
--border-1: #484848;
--accent: #5b9dff;
--accent-fg: #0b0d12;
--accent-weak: rgb(91 157 255 / 0.16);
--accent-dim: #2a4a7a;
--green: #6cb16c;
--green-strong: #7bd88f;
--amber: #ffcc4a;
--red: #d4544f;
--term-bg: #161616;
/* Dark cloned from the agent-orchestrator web app (packages/web). #0a0b0d
* base, #15171b is the only bordered surface, hairline white-alpha lines,
* blue accent, orange=working / amber=needs-you / green=mergeable / red=fail. */
--bg: #0a0b0d;
--bg-1: #15171b;
--bg-2: #1b1d22;
--bg-3: #212329;
--fg: #f4f5f7;
--fg-muted: #9ba1aa;
--fg-passive: #646a73;
--border: rgb(255 255 255 / 0.06);
--border-1: rgb(255 255 255 / 0.1);
--accent: #4d8dff;
--accent-fg: #ffffff;
--accent-weak: rgb(77 141 255 / 0.16);
--accent-dim: #24406e;
--orange: #f59f4c;
--green: #74b98a;
--green-strong: #74b98a;
--amber: #e8c14a;
--red: #ef6b6b;
/* Terminal — dark palette (light override below). */
--term-bg: #15171b;
--term-fg: #d7d7d2;
--term-green: #7bd88f;
--term-green: #74b98a;
--term-dim: #7c7c7c;
--term-blue: #5b9dff;
--term-amber: #ffcc4a;
--shadow: 0 1px 0 rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.45);
--term-blue: #4d8dff;
--term-amber: #e8c14a;
--shadow: 0 1px 0 rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.5);
/* Sidebar surface sits a step below --bg (cloned #08090b). */
--sidebar-bg: #08090b;
--interactive-hover: rgb(255 255 255 / 0.04);
--interactive-active: rgb(255 255 255 / 0.07);
--kanban-column-bg: rgb(255 255 255 / 0.018);
--kanban-pending-glow: rgb(255 255 255 / 0.02);
}
:root[data-theme="light"] {
@ -104,17 +142,22 @@
--accent-fg: #ffffff;
--accent-weak: rgb(37 99 235 / 0.12);
--accent-dim: #bcd2f7;
--sidebar-bg: var(--bg);
--orange: #c2410c;
--green: #1a7f37;
--green-strong: #1a7f37;
--amber: #9a6b00;
--red: #c0392b;
/* Terminal keeps its own dark palette in both themes (it is the agent CLI). */
--term-bg: #161616;
--term-fg: #d7d7d2;
--term-green: #7bd88f;
--term-dim: #7c7c7c;
--term-blue: #5b9dff;
--term-amber: #ffcc4a;
--interactive-hover: rgb(0 0 0 / 0.04);
--interactive-active: rgb(0 0 0 / 0.07);
--kanban-column-bg: rgb(0 0 0 / 0.02);
--kanban-pending-glow: rgb(0 0 0 / 0.03);
--term-bg: #fafafa;
--term-fg: #24292f;
--term-green: #1a7f37;
--term-dim: #6b7280;
--term-blue: #2563eb;
--term-amber: #9a6b00;
--shadow: 0 1px 0 rgb(0 0 0 / 0.03), 0 12px 30px rgb(20 30 50 / 0.1);
}
@ -150,6 +193,14 @@ select {
height: 100%;
}
:root[data-theme="light"] .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 0.12);
}
:root[data-theme="light"] .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 0.2);
}
@keyframes status-pulse {
0%,
100% {
@ -179,3 +230,746 @@ select {
transform: translate(-50%, -50%) scale(1);
}
}
/* ── Resizable panel handles (sidebar + inspector) ──────────────────────── */
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 7px;
z-index: 5;
cursor: col-resize;
background: transparent;
touch-action: none;
}
.resize-handle--right {
right: -3px;
}
.resize-handle--left {
left: -3px;
}
.resize-handle::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: transparent;
transition: background 0.12s ease;
}
.resize-handle:hover::after,
body.is-resizing-x .resize-handle::after {
background: var(--color-accent);
}
body.is-resizing-x {
cursor: col-resize;
user-select: none;
}
/* No width transition while actively dragging the sidebar resize handle
* (shadcn's sidebar animates width for collapse/expand). */
body.is-resizing-x [data-slot="sidebar-gap"],
body.is-resizing-x [data-slot="sidebar-container"] {
transition: none;
}
/* ── Dashboard / session topbar (agent-orchestrator .dashboard-app-header) ─ */
.dashboard-app-header {
display: flex;
height: 56px;
flex-shrink: 0;
align-items: center;
gap: 13px;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: var(--bg);
z-index: 10;
}
.dashboard-app-header__project {
font-size: 14.5px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1;
color: var(--fg);
white-space: nowrap;
}
.topbar-project-line {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.dashboard-app-header__tabs {
display: inline-flex;
align-items: center;
gap: 2px;
}
.dashboard-app-header__tab {
display: inline-flex;
height: 28px;
align-items: center;
border-radius: 6px;
padding: 0 11px;
font-size: 12.5px;
line-height: 1;
color: var(--fg-passive);
transition:
background 0.12s ease,
color 0.12s ease;
}
.dashboard-app-header__tab:hover {
color: var(--fg);
}
.dashboard-app-header__tab.is-active {
background: var(--interactive-active);
color: var(--fg);
}
.dashboard-app-header__spacer {
flex: 1;
min-width: 0;
}
.dashboard-app-header__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
}
.dashboard-app-header__icon-btn {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 7px;
color: var(--fg-muted);
transition:
background 0.12s ease,
color 0.12s ease;
}
.dashboard-app-header__icon-btn:hover {
background: var(--interactive-hover);
color: var(--fg);
}
.dashboard-app-header__primary-btn {
display: inline-flex;
height: 34px;
align-items: center;
gap: 6px;
border-radius: 7px;
padding: 0 15px;
font-size: 13px;
font-weight: 600;
line-height: 1;
background: var(--accent);
color: var(--accent-fg);
transition: filter 0.12s ease;
}
.dashboard-app-header__primary-btn:hover {
filter: brightness(1.08);
}
.dashboard-app-header__primary-btn:disabled {
opacity: 0.6;
}
.dashboard-app-header__accent-btn {
display: inline-flex;
height: 34px;
align-items: center;
gap: 6px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
padding: 0 15px;
font-size: 13px;
font-weight: 600;
line-height: 1;
background: var(--accent-weak);
color: var(--accent);
transition: background 0.12s ease;
}
.dashboard-app-header__accent-btn:hover {
background: color-mix(in srgb, var(--accent-weak) 80%, transparent);
}
.session-topbar.dashboard-app-header {
height: 56px;
gap: 13px;
padding: 0 16px 0 14px;
}
.session-topbar__lead {
display: flex;
min-width: 0;
align-items: center;
gap: 13px;
}
/* macOS titlebar cluster (TitlebarNav)
* Fixed beside the traffic lights so the toggle/history buttons occupy the
* exact same spot in both sidebar states. Lights span x=1466 (y center 20);
* 28px buttons in a 40px strip share that center, and the 6.5px icon inset
* puts the first glyph at ~76px the standard 10px gap after the lights. */
.titlebar-nav {
position: fixed;
top: 0;
left: 70px;
z-index: 20;
display: flex;
height: 40px;
align-items: center;
gap: 2px;
}
.titlebar-nav__btn {
width: 28px;
height: 28px;
}
/* Collapsed-rail topbars start their content past the fixed cluster:
* cluster right edge 70 + 3×28 + 2×2 = 158px, + the header's 13px gap,
* the 48px icon rail = 123px. Transition mirrors the sidebar's width
* animation (200ms linear) so content tracks the moving sidebar edge. */
.dashboard-app-header {
transition: padding-left 0.2s linear;
}
.dashboard-app-header.is-under-titlebar-nav {
padding-left: 123px;
}
.topbar-project-pills-group {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.topbar-identity-sep {
font-size: 12px;
line-height: 1;
color: var(--fg-passive);
}
.session-detail-mode-badge {
display: inline-flex;
height: 24px;
align-items: center;
gap: 4px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0 8px;
font-size: 10px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.05em;
}
.session-detail-mode-badge--neutral {
background: var(--surface);
color: var(--fg-muted);
}
.session-topbar__identity {
display: flex;
min-width: 0;
align-items: center;
gap: 11px;
}
.session-topbar__branch {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1;
color: var(--fg-passive);
}
/* ── Session inspector rail (agent-orchestrator SessionInspector) ───────── */
.session-inspector {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--bg);
border-left: 1px solid var(--border);
}
.session-inspector__tabs {
display: flex;
align-items: center;
gap: 3px;
height: 47px;
flex-shrink: 0;
padding: 0 11px;
border-bottom: 1px solid var(--border);
}
.session-inspector__tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 7px 6px;
border-radius: 7px;
font-size: 11.5px;
font-weight: 600;
color: var(--fg-passive);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.session-inspector__tab:hover {
background: var(--interactive-hover);
color: var(--fg);
}
.session-inspector__tab.is-active {
background: var(--interactive-active);
color: var(--fg);
}
.session-inspector__tab-icon {
display: inline-flex;
flex-shrink: 0;
}
.session-inspector__tab-icon svg {
width: 14px;
height: 14px;
}
.session-inspector__tab-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-inspector__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px 18px 40px;
}
/* Inspector resize handle (shadcn resizable separator, AO visual): a 7px
* transparent grab strip whose centered 1px line lights accent on hover, drag
* (rrp sets data-separator="active"), and keyboard focus. */
.session-inspector__resize-handle {
width: 7px;
cursor: col-resize;
background: transparent;
touch-action: none;
}
.session-inspector__resize-handle::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: transparent;
transition: background 0.12s ease;
}
.session-inspector__resize-handle:hover::after,
.session-inspector__resize-handle:focus-visible::after,
.session-inspector__resize-handle[data-separator="active"]::after {
background: var(--accent);
}
/* Collapse/expand animation for the inspector panel: rrp v4 drives panel
* sizes via flex-grow, which is animatable. Suppress it while the separator
* is actively dragged so resizing tracks the pointer 1:1. */
.session-split #inspector[data-panel] {
transition: flex-grow 0.2s linear;
}
.session-split:has([data-separator="active"]) #inspector[data-panel] {
transition: none;
}
.inspector-section {
margin-bottom: 22px;
}
.inspector-section__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 11px;
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--fg-passive);
}
.inspector-section__link {
font-size: 11px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--accent);
}
.inspector-section__link:hover {
text-decoration: underline;
}
.inspector-empty {
font-size: 12px;
color: var(--fg-muted);
line-height: 1.5;
}
.inspector-empty--center {
padding: 24px 8px;
text-align: center;
}
.inspector-empty--browser {
display: flex;
flex-direction: column;
align-items: center;
gap: 9px;
padding: 48px 18px;
text-align: center;
}
.inspector-empty--browser svg {
width: 30px;
height: 30px;
color: var(--fg-passive);
}
.inspector-empty--browser p {
font-size: 12.5px;
color: var(--fg-muted);
}
.inspector-empty--browser span {
font-size: 11.5px;
color: var(--fg-passive);
line-height: 1.5;
}
.inspector-kv {
display: flex;
flex-direction: column;
gap: 1px;
}
.inspector-kv__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 2px;
font-size: 12.5px;
}
.inspector-kv__k {
width: 74px;
flex-shrink: 0;
color: var(--fg-muted);
}
.inspector-kv__v {
min-width: 0;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector-kv__v--mono {
font-family: var(--font-mono);
font-size: 11.5px;
}
.inspector-timeline {
position: relative;
padding-left: 20px;
}
.inspector-timeline::before {
content: "";
position: absolute;
left: 5px;
top: 4px;
bottom: 6px;
width: 1px;
background: var(--border);
}
.inspector-timeline__ev {
position: relative;
padding: 0 0 15px;
}
.inspector-timeline__ev:last-child {
padding-bottom: 0;
}
.inspector-timeline__node {
position: absolute;
left: -18px;
top: 3px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--fg-passive);
box-shadow: 0 0 0 3px var(--bg);
}
.inspector-timeline__ev--now .inspector-timeline__node {
background: var(--orange);
box-shadow:
0 0 0 3px var(--bg),
0 0 8px var(--orange);
}
.inspector-timeline__ev--good .inspector-timeline__node {
background: var(--green);
}
.inspector-timeline__ev--warn .inspector-timeline__node {
background: var(--amber);
}
.inspector-timeline__et {
font-size: 12px;
color: var(--fg);
line-height: 1.45;
}
.inspector-timeline__et b {
font-weight: 600;
}
.inspector-timeline__badge {
display: inline-flex;
vertical-align: middle;
}
.inspector-timeline__detail {
color: var(--fg-muted);
}
.inspector-timeline__ets {
font-size: 10.5px;
color: var(--fg-passive);
font-family: var(--font-mono);
margin-top: 2px;
}
/* Changes tab — git rail (second screenshot) */
.inspector-changes__head {
display: flex;
align-items: center;
gap: 8px;
margin: -6px -6px 10px;
padding: 0 6px 10px;
border-bottom: 1px solid var(--border);
}
.inspector-changes__title {
font-size: 13px;
font-weight: 600;
color: var(--fg);
}
.inspector-changes__count {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-passive);
}
.inspector-changes__actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
}
.inspector-changes__action {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--fg-muted);
transition: color 0.12s ease;
}
.inspector-changes__action:hover {
color: var(--fg);
}
.inspector-changes__action--danger {
color: var(--red);
}
.inspector-changes__action--danger:hover {
opacity: 0.85;
}
.inspector-changes__action--end {
margin-left: auto;
}
.inspector-changes__list {
min-height: 120px;
margin-bottom: 12px;
}
.inspector-changes__file {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 12px;
transition: background 0.12s ease;
}
.inspector-changes__file:hover {
background: var(--interactive-hover);
}
.inspector-changes__path {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
}
.inspector-changes__add {
flex-shrink: 0;
color: var(--green);
}
.inspector-changes__del {
flex-shrink: 0;
color: var(--red);
}
.inspector-changes__stage {
width: 13px;
height: 13px;
flex-shrink: 0;
color: var(--fg-passive);
}
.inspector-changes__stage.is-staged {
color: var(--accent);
}
.inspector-changes__commit {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.inspector-changes__input,
.inspector-changes__textarea {
width: 100%;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
padding: 6px 10px;
font-size: 12.5px;
color: var(--fg);
}
.inspector-changes__input::placeholder,
.inspector-changes__textarea::placeholder {
color: var(--fg-passive);
}
.inspector-changes__input:focus-visible,
.inspector-changes__textarea:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent-weak);
outline-offset: 0;
}
.inspector-changes__textarea {
resize: none;
}
.inspector-changes__footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-passive);
}
.inspector-changes__footer svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.inspector-changes__branch {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-muted);
}
.inspector-changes__pr {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
border-radius: 6px;
border: 1px solid var(--border);
padding: 2px 8px;
color: var(--fg-muted);
transition: background 0.12s ease;
}
.inspector-changes__pr:hover {
background: var(--interactive-hover);
}

View File

@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import {
attentionZone,
sessionIsActive,
sessionNeedsAttention,
toAgentProvider,
toSessionStatus,
workerDisplayStatus,
workerStatusPulses,
type AttentionZone,
type SessionStatus,
type WorkspaceSession,
} from "./workspace";
@ -26,7 +29,6 @@ function sessionWith(overrides: Partial<WorkspaceSession>): WorkspaceSession {
describe("toSessionStatus", () => {
it("passes through a known status", () => {
expect(toSessionStatus("mergeable")).toBe("mergeable");
expect(toSessionStatus("no_signal")).toBe("no_signal");
});
it("overrides to terminated when the session is terminated", () => {
@ -51,7 +53,6 @@ describe("workerDisplayStatus", () => {
["needs_input", "needs_you"],
["changes_requested", "needs_you"],
["review_pending", "needs_you"],
["no_signal", "needs_you"],
["ci_failed", "ci_failed"],
["approved", "mergeable"],
["mergeable", "mergeable"],
@ -106,3 +107,29 @@ describe("toAgentProvider", () => {
expect(toAgentProvider(undefined)).toBe("codex");
});
});
describe("attentionZone", () => {
const cases: Array<[SessionStatus, AttentionZone]> = [
["mergeable", "merge"],
["approved", "merge"],
["needs_input", "action"],
["ci_failed", "action"],
["changes_requested", "action"],
["review_pending", "pending"],
["pr_open", "pending"],
["draft", "pending"],
["working", "working"],
["idle", "working"],
["merged", "done"],
["terminated", "done"],
];
it.each(cases)("buckets %s into the %s zone", (status, zone) => {
expect(attentionZone(sessionWith({ status }))).toBe(zone);
});
it("prioritizes merge as the highest-ROI zone", () => {
// merge is checked before action/pending so an approved PR always surfaces.
expect(attentionZone(sessionWith({ status: "approved" }))).toBe("merge");
});
});

View File

@ -10,7 +10,6 @@ export type SessionStatus =
| "merged"
| "needs_input"
| "idle"
| "no_signal"
| "terminated";
const sessionStatuses = new Set<SessionStatus>([
@ -25,7 +24,6 @@ const sessionStatuses = new Set<SessionStatus>([
"merged",
"needs_input",
"idle",
"no_signal",
"terminated",
]);
@ -67,6 +65,8 @@ export type ChangedFile = {
staged?: boolean;
};
export type SessionKind = "worker" | "orchestrator";
export type WorkspaceSession = {
id: string;
terminalHandleId?: string;
@ -74,8 +74,12 @@ export type WorkspaceSession = {
workspaceName: string;
title: string;
provider: AgentProvider;
kind?: SessionKind;
branch: string;
status: SessionStatus;
/** ISO timestamp from the daemon — used for relative time in the inspector. */
createdAt?: string;
/** ISO timestamp from the daemon. */
updatedAt: string;
/** The session's git diff against its base, when known. */
changedFiles?: ChangedFile[];
@ -101,9 +105,6 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta
case "needs_input":
case "changes_requested":
case "review_pending":
// no_signal: the daemon has never heard from this agent — a human
// should look at the pane, so it surfaces as needs-you.
case "no_signal":
return "needs_you";
case "ci_failed":
return "ci_failed";
@ -118,6 +119,22 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta
}
}
export function isOrchestratorSession(session: WorkspaceSession): boolean {
return session.kind === "orchestrator" || session.id.endsWith("-orchestrator");
}
export function findProjectOrchestrator(
workspaces: WorkspaceSummary[],
projectId: string,
): WorkspaceSession | undefined {
const workspace = workspaces.find((w) => w.id === projectId);
return workspace?.sessions.find(isOrchestratorSession);
}
export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] {
return sessions.filter((s) => !isOrchestratorSession(s));
}
export function sessionIsActive(session: WorkspaceSession): boolean {
return session.status !== "merged" && session.status !== "terminated";
}
@ -144,6 +161,55 @@ export function workerStatusPulses(status: WorkerDisplayStatus): boolean {
return status === "working" || status === "needs_you";
}
/**
* Kanban attention zone, ordered by human-action urgency ported from
* agent-orchestrator's getAttentionLevel (packages/web/src/lib/types.ts),
* collapsed to its default "simple" set and rebound to reverbcode's
* {@link SessionStatus}. The board groups sessions into these columns so the
* highest-ROrI work (a one-click merge) sits leftmost.
*/
export type AttentionZone = "merge" | "action" | "pending" | "working" | "done";
/** Columns left→right, most-urgent first. "done" is the archive column. */
export const attentionZoneOrder: AttentionZone[] = ["merge", "action", "pending", "working", "done"];
export const attentionZoneLabel: Record<AttentionZone, string> = {
merge: "Ready to merge",
action: "Needs you",
pending: "Pending",
working: "Working",
done: "Done",
};
export function attentionZone(session: WorkspaceSession): AttentionZone {
switch (session.status) {
// Terminal — archive.
case "merged":
case "terminated":
return "done";
// One click to clear — highest ROI, checked first.
case "approved":
case "mergeable":
return "merge";
// Agent waiting on a human (respond) or a problem to investigate (review);
// agent-orchestrator collapses these into one "action" zone by default.
case "needs_input":
case "ci_failed":
case "changes_requested":
return "action";
// Waiting on an external reviewer / CI — nothing to do right now.
case "review_pending":
case "pr_open":
case "draft":
return "pending";
// Agents doing their thing — don't interrupt.
case "working":
case "idle":
default:
return "working";
}
}
export type WorkspaceSummary = {
id: string;
name: string;

View File

@ -17,7 +17,11 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/renderer/*"]
}
},
"include": [
"src/**/*.ts",

View File

@ -1,7 +1,9 @@
// defineConfig comes from vitest/config (a superset of vite's) so the `test`
// block below typechecks; the produced config is a plain vite config object.
// block typechecks; vitest itself must be pointed at this file explicitly
// (package.json test script) because it only auto-discovers vite.config.*.
import { defineConfig } from "vitest/config";
import type { Plugin } from "vite";
import { fileURLToPath, URL } from "node:url";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@ -37,6 +39,12 @@ const injectCspMeta: Plugin = {
};
export default defineConfig({
// "@/" → the renderer root (src/renderer), the shadcn/ui import convention.
resolve: {
alias: {
"@": fileURLToPath(new URL("./src/renderer", import.meta.url)),
},
},
// Dev proxy for VITE_NO_ELECTRON=1 browser preview — forwards /api and /mux
// to the daemon so the renderer can be tested against a running daemon from
// a plain browser without an Electron shell.