feat(renderer): clone agent-orchestrator shell and inspector
This commit is contained in:
parent
0021aa6097
commit
e493de6ad7
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
42
DESIGN.md
42
DESIGN.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
node-linker=hoisted
|
||||
minimum-release-age=0
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
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. */}
|
||||
<button
|
||||
aria-label="Orchestrator"
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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",
|
||||
"mt-px h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
working ? "animate-status-pulse bg-working" : "bg-passive",
|
||||
)}
|
||||
onClick={selectOrchestrator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (200–420px,
|
||||
// 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 (
|
||||
<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 board"
|
||||
className={cn(
|
||||
"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={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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SessionsBoard } from "../components/SessionsBoard";
|
||||
|
||||
export const Route = createFileRoute("/_shell/")({
|
||||
component: () => <SessionsBoard />,
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PullRequestsPage } from "../components/PullRequestsPage";
|
||||
|
||||
export const Route = createFileRoute("/_shell/prs")({
|
||||
component: PullRequestsPage,
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ReviewDashboard } from "../components/ReviewDashboard";
|
||||
|
||||
export const Route = createFileRoute("/_shell/review")({
|
||||
component: ReviewDashboard,
|
||||
});
|
||||
|
|
@ -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" });
|
||||
},
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { App } from "../App";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: App,
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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=14–66 (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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/renderer/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue