diff --git a/proto/.gitignore b/proto/.gitignore new file mode 100644 index 0000000..e8e450b --- /dev/null +++ b/proto/.gitignore @@ -0,0 +1 @@ +gen/ diff --git a/proto/beads/v1/formula.proto b/proto/beads/v1/formula.proto new file mode 100644 index 0000000..ef89fbd --- /dev/null +++ b/proto/beads/v1/formula.proto @@ -0,0 +1,55 @@ +// beads.v1 — formula schema and listing types. +// +// Formulas are workflow templates authored in TOML. The schema describes +// permitted fields per scope (top-level / step / var). The listing surface +// enumerates discoverable formula files per configured directory. + +syntax = "proto3"; + +package beads.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/cwalv/beads-ui-prototype/proto/gen/go/beads/v1;beadsv1"; + +// FormulaSchemaField describes one permitted field at some scope of a +// formula TOML file. Mirrors bd-server's hand-curated schema (the +// canonical version in bd is still future work — see Decision 1). +message FormulaSchemaField { + string key = 1; + // Canonical type values: "string" | "string[]" | "int" | "bool" | + // "enum" | "table". Treat unknowns as opaque text. + string type = 2; + bool required = 3; + string description = 4; + // Suggested values when type == "enum". + repeated string enum = 5; + // If true, values outside `enum` are an error rather than a hint. + bool enum_strict = 6; + string example = 7; + reserved 8 to 19; +} + +// FormulaSchema groups schema fields by scope. +message FormulaSchema { + // Schema revision string (e.g. "0.1"). + string version = 1; + // Top-level fields (formula, description, kind, extends, contract). + repeated FormulaSchemaField top_level = 2; + // Per-step fields (id, title, needs, max_attempts, on_exhausted, …). + repeated FormulaSchemaField step = 3; + // Per-variable fields (name, default, required). + repeated FormulaSchemaField var = 4; + reserved 5 to 19; +} + +// FormulaEntry is one discoverable formula file in a configured dir. +message FormulaEntry { + // The filename stem (e.g. "mol-do-work" for "mol-do-work.formula.toml"). + string name = 1; + // Absolute or workspace-relative path to the .formula.toml file. + string path = 2; + int64 size = 3; + google.protobuf.Timestamp mtime = 4; + reserved 5 to 19; +} diff --git a/proto/beads/v1/service.proto b/proto/beads/v1/service.proto new file mode 100644 index 0000000..4e13334 --- /dev/null +++ b/proto/beads/v1/service.proto @@ -0,0 +1,162 @@ +// beads.v1 — read-side service surface. +// +// v1 covers reads only. Write-side RPCs (Create/Update/Close/Comment, dep +// mutations) are deferred to v2 (see Decision 1 in +// projects/foundations/docs/beads-ui/architecture-decisions.md). +// +// Wire format is JSON via protojson on existing REST endpoints; gRPC is +// not used in v1. + +syntax = "proto3"; + +package beads.v1; + +import "beads/v1/formula.proto"; +import "beads/v1/types.proto"; + +option go_package = "github.com/cwalv/beads-ui-prototype/proto/gen/go/beads/v1;beadsv1"; + +// BeadsService is the read-side bd interface. All RPCs scope by workspace +// name; the resolver looks up the corresponding `.beads/` root. +service BeadsService { + // List returns issues matching the filter, with dependency / comment + // counts populated. Mirrors `bd list --json`. + rpc List(ListRequest) returns (ListResponse); + // Show returns full issue details including labels, dependencies (with + // depended-on bead expanded), dependents, comments, computed parent, + // and epic-progress fields. Mirrors `bd show --json`. + rpc Show(ShowRequest) returns (ShowResponse); + // Ready returns ready-to-work issues (and optionally a blocked / + // explanation breakdown). Mirrors `bd ready --json`. + rpc Ready(ReadyRequest) returns (ReadyResponse); + // ListWorkspaces enumerates workspaces configured at the bd-server. + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + // GetFormulaSchema returns the per-scope formula field schema. + rpc GetFormulaSchema(GetFormulaSchemaRequest) returns (GetFormulaSchemaResponse); + // ListFormulas returns formula files in a configured formula dir. + rpc ListFormulas(ListFormulasRequest) returns (ListFormulasResponse); +} + +// ===== List ===== + +message ListRequest { + // Workspace name (matches a bd-server-configured workspace). + string workspace = 1; + // Optional filters; left empty defaults to bd's default scope. + optional string status = 2; + optional string type = 3; + optional string assignee = 4; + // Filter by metadata key=value (top-level, AND across multiple). + map metadata_fields = 5; + // Filter by metadata key existence (no value match). + repeated string has_metadata_keys = 6; + // Maximum rows; bd defaults apply when unset. + optional int32 limit = 7; + reserved 8 to 19; +} + +message ListResponse { + repeated Bead beads = 1; + // True when results were truncated by `limit`. + bool truncated = 2; + reserved 3 to 19; +} + +// ===== Show ===== + +message ShowRequest { + string workspace = 1; + // One or more issue IDs. bd resolves cross-prefix routing per ID. + repeated string ids = 2; + reserved 3 to 19; +} + +message ShowResponse { + // One bead per requested ID, in request order. Missing IDs are omitted. + repeated Bead beads = 1; + // IDs that could not be resolved. + repeated string not_found = 2; + reserved 3 to 19; +} + +// ===== Ready ===== + +message ReadyRequest { + string workspace = 1; + optional string assignee = 2; + // Filter by metadata key=value (e.g. `gc.routed_to=foundations/worker`). + map metadata_fields = 3; + // When true, restrict to rows with no assignee. + bool unassigned = 4; + // When true, return a `ReadyExplanation`-style payload that includes + // blocked items, cycles, and per-row reasoning. + bool explain = 5; + optional int32 limit = 6; + reserved 7 to 19; +} + +message ReadyItem { + Bead bead = 1; + // Human-readable summary of why this row is ready. + string reason = 2; + // Blockers that have already cleared (informational). + repeated string resolved_blockers = 3; +} + +message BlockedItem { + Bead bead = 1; + // Issue IDs currently blocking this row. + repeated string blocked_by = 2; + string reason = 3; +} + +message ReadyExplanation { + repeated ReadyItem ready = 1; + repeated BlockedItem blocked = 2; + // Detected dependency cycles, each as an ordered list of issue IDs. + message Cycle { + repeated string ids = 1; + } + repeated Cycle cycles = 3; +} + +message ReadyResponse { + // Plain ready list, returned when `explain` was false. + repeated Bead beads = 1; + // Populated when `explain` was true; `beads` is empty in that case. + optional ReadyExplanation explanation = 2; + reserved 3 to 19; +} + +// ===== Workspaces ===== + +message ListWorkspacesRequest { + reserved 1 to 19; +} + +message ListWorkspacesResponse { + repeated Workspace workspaces = 1; + reserved 2 to 19; +} + +// ===== Formulas ===== + +message GetFormulaSchemaRequest { + reserved 1 to 19; +} + +message GetFormulaSchemaResponse { + FormulaSchema schema = 1; + reserved 2 to 19; +} + +message ListFormulasRequest { + // bd-server-configured formula directory name (e.g. "local", "shared"). + string dir = 1; + reserved 2 to 19; +} + +message ListFormulasResponse { + repeated FormulaEntry formulas = 1; + reserved 2 to 19; +} diff --git a/proto/beads/v1/types.proto b/proto/beads/v1/types.proto new file mode 100644 index 0000000..fcddd3a --- /dev/null +++ b/proto/beads/v1/types.proto @@ -0,0 +1,252 @@ +// beads.v1 — read-side wire schema for the bd issue tracker. +// +// Field numbers are forever once shipped. Reserve liberally; never reuse. +// +// Customs: bd allows operators to register custom statuses, types, and +// dep types via `bd config set status.custom`, `types.custom`, and +// per-dependency creation. Therefore status / type / dep_type are `string`, +// not proto enums. Canonical values are listed in the field comments; +// renderers narrow on those and pass customs through with a neutral +// fallback. +// +// JSON names: bd's existing wire format is snake_case (Go `json:"..."` tags +// on `internal/types/types.go::Issue`). Multi-word fields therefore set +// `[json_name = ""]` so that protojson serialization and the +// generated TS `*Json` types match bd-server's current output without a +// translation layer. + +syntax = "proto3"; + +package beads.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/cwalv/beads-ui-prototype/proto/gen/go/beads/v1;beadsv1"; + +// Bead is one row of bd's `issues` (or `wisps`) table, plus the auxiliary +// data populated by `bd show --json` / `bd list --json` (labels, +// dependencies, comments, computed parent, epic-progress rollup). +// +// Sources: github/gastownhall/beads/internal/types/types.go (`Issue`, +// `IssueDetails`, `IssueWithCounts`). +message Bead { + // ===== Identification ===== + string id = 1; + reserved 2 to 9; // future identification surface (content_hash is JSON-excluded) + + // ===== Body / content ===== + string title = 10; + string description = 11; + string design = 12; + string acceptance_criteria = 13 [json_name = "acceptance_criteria"]; + string notes = 14; + string spec_id = 15 [json_name = "spec_id"]; + reserved 16 to 19; + + // ===== Workflow ===== + // Canonical statuses: open | in_progress | blocked | deferred | closed | + // pinned | hooked. Customs are accepted; `pinned` is also a separate + // boolean column (see `pinned` field below). + string status = 20; + // 0=critical (P0), 1=high, 2=medium (default), 3=low, 4=backlog. No + // omit-on-zero: 0 is a valid value distinct from missing. + int32 priority = 21; + // Canonical types: bug | feature | task | epic | chore | decision | + // message | molecule | spike | story | milestone | event. Removed but + // commonly seen as customs: gate | convoy | merge-request | slot | + // agent | role | rig. + // + // bd's wire field is `issue_type` (Go: Issue.IssueType IssueType + // `json:"issue_type,omitempty"`). The proto field number stays 22 + // (forever); only the JSON name needs to match bd's wire format. + string type = 22 [json_name = "issue_type"]; + reserved 23 to 29; + + // ===== Assignment ===== + string assignee = 30; + string owner = 31; + optional int32 estimated_minutes = 32 [json_name = "estimated_minutes"]; + reserved 33 to 39; + + // ===== Timestamps ===== + google.protobuf.Timestamp created_at = 40 [json_name = "created_at"]; + string created_by = 41 [json_name = "created_by"]; + google.protobuf.Timestamp updated_at = 42 [json_name = "updated_at"]; + optional google.protobuf.Timestamp started_at = 43 [json_name = "started_at"]; + optional google.protobuf.Timestamp closed_at = 44 [json_name = "closed_at"]; + string close_reason = 45 [json_name = "close_reason"]; + string closed_by_session = 46 [json_name = "closed_by_session"]; + reserved 47 to 49; + + // ===== Time-based scheduling ===== + optional google.protobuf.Timestamp due_at = 50 [json_name = "due_at"]; + optional google.protobuf.Timestamp defer_until = 51 [json_name = "defer_until"]; + reserved 52 to 59; + + // ===== External integration ===== + optional string external_ref = 60 [json_name = "external_ref"]; + string source_system = 61 [json_name = "source_system"]; + reserved 62 to 69; + + // ===== Custom metadata ===== + // Free-form JSON. Validated as well-formed JSON by bd; optionally + // schema-checked via `metadata.validation` config. Wire format is the + // raw JSON object (`google.protobuf.Struct` round-trips as such). + google.protobuf.Struct metadata = 70; + reserved 71 to 79; + + // ===== Compaction metadata ===== + int32 compaction_level = 80 [json_name = "compaction_level"]; + optional google.protobuf.Timestamp compacted_at = 81 [json_name = "compacted_at"]; + optional string compacted_at_commit = 82 [json_name = "compacted_at_commit"]; + int32 original_size = 83 [json_name = "original_size"]; + reserved 84 to 89; + + // 90-99 reserved for internal-routing fields that are currently + // JSON-excluded in the Go struct (source_repo, IDPrefix, + // PrefixOverride). They are not part of the public read surface. + reserved 90 to 99; + + // ===== Relational data (populated by bd show; partial for bd list) ===== + repeated string labels = 100; + // Edges where this bead depends on others. Each element is the + // depended-on bead with `dependency_type` populated to identify the + // edge kind. Mirrors `IssueDetails.Dependencies`. + repeated Bead dependencies = 101; + // Edges where other beads depend on this one. Same shape as above. + repeated Bead dependents = 102; + repeated Comment comments = 103; + // Convenience: the parent ID computed from the `parent-child` edge in + // dependencies, when present. Mirrors `IssueDetails.Parent`. + optional string parent = 104; + reserved 105 to 109; + + // ===== Messaging / wisp routing ===== + string sender = 110; + bool ephemeral = 111; + bool no_history = 112 [json_name = "no_history"]; + string wisp_type = 113 [json_name = "wisp_type"]; + reserved 114 to 119; + + // ===== Context markers ===== + // Note: this is the `pinned` boolean column — a persistent context + // marker. The status value `'pinned'` is independent (it lives in + // `status` above). + bool pinned = 120; + bool is_template = 121 [json_name = "is_template"]; + reserved 122 to 129; + + // 130-139 reserved for compound molecule lineage (`bonded_from`). + reserved 130 to 139; + + // ===== Gate fields (async coordination) ===== + string await_type = 140 [json_name = "await_type"]; + string await_id = 141 [json_name = "await_id"]; + optional google.protobuf.Duration timeout = 142; + repeated string waiters = 143; + reserved 144 to 149; + + // ===== Source tracing (formula cooking origin) ===== + string source_formula = 150 [json_name = "source_formula"]; + string source_location = 151 [json_name = "source_location"]; + reserved 152 to 159; + + // ===== Molecule / work classification ===== + // Canonical mol_type: swarm | patrol | work (empty defaults to work). + string mol_type = 160 [json_name = "mol_type"]; + // Canonical work_type: mutex | open_competition (empty defaults to mutex). + string work_type = 161 [json_name = "work_type"]; + reserved 162 to 169; + + // ===== Event fields (issue_type='event' only) ===== + string event_kind = 170 [json_name = "event_kind"]; + string actor = 171; + string target = 172; + string payload = 173; + reserved 174 to 179; + + // ===== Epic progress rollup (populated by bd show for type=epic) ===== + optional int32 epic_total_children = 180 [json_name = "epic_total_children"]; + optional int32 epic_closed_children = 181 [json_name = "epic_closed_children"]; + optional bool epic_closeable = 182 [json_name = "epic_closeable"]; + reserved 183 to 199; + + // ===== List response augments (IssueWithCounts) ===== + // Populated by bd list --json. Not present on bd show responses. + optional int32 dependency_count = 200 [json_name = "dependency_count"]; + optional int32 dependent_count = 201 [json_name = "dependent_count"]; + optional int32 comment_count = 202 [json_name = "comment_count"]; + reserved 203 to 219; + + // ===== Edge context ===== + // Set when this Bead appears inside another bead's `dependencies` or + // `dependents` array — identifies the edge kind connecting to the + // outer bead. Empty when this bead is the top-level subject. + // Mirrors `IssueWithDependencyMetadata.DependencyType`. + // Canonical dep types: blocks | parent-child | conditional-blocks | + // waits-for | related | discovered-from | replies-to | relates-to | + // duplicates | supersedes | authored-by | assigned-to | approved-by | + // attests | tracks | until | caused-by | validates | delegated-from. + string dependency_type = 220 [json_name = "dependency_type"]; + reserved 221 to 299; +} + +// Dependency is a single edge in the `dependencies` table. Most clients +// receive edges via `Bead.dependencies` / `Bead.dependents` (which embed +// the full target bead); this message is for raw edge listings. +message Dependency { + string issue_id = 1 [json_name = "issue_id"]; + string depends_on_id = 2 [json_name = "depends_on_id"]; + // Canonical dep types — see Bead.dependency_type. + string type = 3; + google.protobuf.Timestamp created_at = 4 [json_name = "created_at"]; + string created_by = 5 [json_name = "created_by"]; + // JSON blob carrying type-specific edge data (`WaitsForMeta`, + // `AttestsMeta`, etc.). + string metadata = 6; + // Groups conversation edges (replies-to chains). + string thread_id = 7 [json_name = "thread_id"]; + reserved 8 to 19; +} + +// Comment is a row in the `comments` table. +message Comment { + string id = 1; + string issue_id = 2 [json_name = "issue_id"]; + string author = 3; + string text = 4; + google.protobuf.Timestamp created_at = 5 [json_name = "created_at"]; + reserved 6 to 19; +} + +// Event is a row in the `events` table (audit trail). Distinct from +// type=event beads (which use Bead.event_kind / actor / target / payload). +message Event { + string id = 1; + string issue_id = 2 [json_name = "issue_id"]; + // Canonical kinds: created | updated | status_changed | commented | + // closed | reopened | dependency_added | dependency_removed | + // label_added | label_removed | compacted. + string event_type = 3 [json_name = "event_type"]; + string actor = 4; + optional string old_value = 5 [json_name = "old_value"]; + optional string new_value = 6 [json_name = "new_value"]; + optional string comment = 7; + google.protobuf.Timestamp created_at = 8 [json_name = "created_at"]; + reserved 9 to 19; +} + +// Workspace is one entry in bd-server's `/v1/workspaces` response. +// Multiple workspaces share one bd-server; each maps to a `.beads/` root. +message Workspace { + string name = 1; + string path = 2; + bool reachable = 3; + // Optional UI hints (not currently set by bd-server, reserved for + // future workspace metadata). + optional string description = 4; + optional string color = 5; + reserved 6 to 19; +} diff --git a/proto/buf.gen.go.yaml b/proto/buf.gen.go.yaml new file mode 100644 index 0000000..d3fa34d --- /dev/null +++ b/proto/buf.gen.go.yaml @@ -0,0 +1,14 @@ +version: v2 +clean: true +plugins: + # Go via google.golang.org/protobuf/cmd/protoc-gen-go. Requires + # `go install google.golang.org/protobuf/cmd/protoc-gen-go@latest`. Run + # via `npm run proto:generate:go` from ui/ — kept separate from the + # default TS codegen so npm-only environments (e.g. CI without Go) stay + # functional. + - local: protoc-gen-go + out: gen/go + opt: + - paths=source_relative +inputs: + - directory: . diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 0000000..ed0b29c --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,15 @@ +version: v2 +clean: true +plugins: + # TypeScript / JavaScript via bufbuild/protobuf-es. Output goes into the UI + # source tree so vite/tsc pick it up without further config. This is the + # default codegen target — ships with the npm devDeps, no system tooling + # required. + - local: ../ui/node_modules/.bin/protoc-gen-es + out: ../ui/src/gen + opt: + - target=ts + - import_extension=js + - json_types=true +inputs: + - directory: . diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 0000000..6a9ca1c --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,12 @@ +version: v2 +modules: + - path: . + name: buf.build/cwalv/beads +lint: + use: + - STANDARD + except: + - PACKAGE_VERSION_SUFFIX +breaking: + use: + - WIRE_JSON diff --git a/ui/.gitignore b/ui/.gitignore index 205a6bf..bfef2d9 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .vite/ *.local +*.tsbuildinfo +src/gen/ diff --git a/ui/package-lock.json b/ui/package-lock.json index 082653f..908921f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "name": "beads-ui", + "hasInstallScript": true, "dependencies": { + "@bufbuild/protobuf": "^2.2.0", "@panzoom/panzoom": "^4.6.2", "@types/dagre": "^0.7.54", "dagre": "^0.8.5", @@ -15,6 +17,8 @@ "react-router-dom": "^6.26.0" }, "devDependencies": { + "@bufbuild/buf": "^1.47.0", + "@bufbuild/protoc-gen-es": "^2.2.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", @@ -348,6 +352,207 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/buf": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.68.4.tgz", + "integrity": "sha512-r+QG3mlQ1h4agDwYxnPz1AdVLubgt09sRLnnlLMj0MVUOWjdV92i/20ZmSxn6+thouGlpr2A+VKPgy8NShchrA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.68.4", + "@bufbuild/buf-darwin-x64": "1.68.4", + "@bufbuild/buf-linux-aarch64": "1.68.4", + "@bufbuild/buf-linux-armv7": "1.68.4", + "@bufbuild/buf-linux-x64": "1.68.4", + "@bufbuild/buf-win32-arm64": "1.68.4", + "@bufbuild/buf-win32-x64": "1.68.4" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.68.4.tgz", + "integrity": "sha512-57VDmK/U8ysWLsydDy/UzQ4gxS8LZyBiAnq1WWk33aUCkHr1poDgAqdOSzjIlBqoe/Mx/+pbVk2/OuaiY8T6aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.68.4.tgz", + "integrity": "sha512-B552Ke9CMDOIyCZGXqSeEej2+13B1PlesZj3j9DCQml86vVEJLVNw9JIUBRE47S31h0qx/Qzptm1N898gaaXTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.68.4.tgz", + "integrity": "sha512-bbe/EQ6vr5ytdoE/llICbp2jPzMDPnT0F0RwlE2rm1sU6crYNJQPOg+QG/NVabL8XimLooRZ1WgPEeZFyAW/hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.68.4.tgz", + "integrity": "sha512-Q1SHNEYYiEpz/IwIfBrdVvgd8VCQlPue6J6IzMWNPCjwhhZViQNAMXIo4Huvz0Xhrwe3shYbdEx6uj/tu49dug==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.68.4.tgz", + "integrity": "sha512-Bo8OaMc76+s23xDZ96LhHunBJYdwTyDOdLOvjiebMQ6MTkcPU/B+ecIwJtRj1FGh9NWWRpT/YaroTpOPVNXG/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.68.4.tgz", + "integrity": "sha512-knbrAF8maJ+7GbsZ9Wp7GuZ72SEiBv/1/0vgZSgdzPn2VytciTM7HRGlsglk9sPTX18WlKZeOY/+pkJpC/RGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.68.4", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.68.4.tgz", + "integrity": "sha512-JnUSCPNtmdSWaj9mA7ioLC2y8kpvX91FEYwxk3VrNukWtctiMTbhbS9CwbUdZuHjJSvWDLdKIS75Iyj16ufplg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.12.0.tgz", + "integrity": "sha512-d9htF6jEkSwPbp9d/vSmZOBF7eeG18AvTMKmVg4I23afnrQOxL2w3WOXa9TaufMCyu24QakEUb4vux8apI5e7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@bufbuild/protoplugin": "2.12.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.12.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.12.0.tgz", + "integrity": "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1624,6 +1829,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", diff --git a/ui/package.json b/ui/package.json index 94db017..98e654b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,15 +3,27 @@ "private": true, "type": "module", "scripts": { + "postinstall": "npm run proto:generate", + "predev": "npm run proto:generate", "dev": "vite", + "prebuild": "npm run proto:generate", "build": "tsc -b && vite build", "preview": "vite preview", + "pretest": "npm run proto:generate", "test": "vitest run", + "pretest:watch": "npm run proto:generate", "test:watch": "vitest", "lint": "eslint . --ext .ts,.tsx", - "typecheck": "tsc --noEmit" + "pretypecheck": "npm run proto:generate", + "typecheck": "tsc --noEmit", + "proto:generate": "cd ../proto && buf generate", + "proto:generate:go": "cd ../proto && buf generate --template buf.gen.go.yaml", + "proto:lint": "cd ../proto && buf lint", + "proto:format": "cd ../proto && buf format -w", + "proto:breaking": "cd ../proto && buf breaking --against '../.git#subdir=proto,branch=main'" }, "dependencies": { + "@bufbuild/protobuf": "^2.2.0", "@panzoom/panzoom": "^4.6.2", "@types/dagre": "^0.7.54", "dagre": "^0.8.5", @@ -21,6 +33,8 @@ "react-router-dom": "^6.26.0" }, "devDependencies": { + "@bufbuild/buf": "^1.47.0", + "@bufbuild/protoc-gen-es": "^2.2.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8c6c7cd..e487e31 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useState, useCallback, useEffect } from 'react'; import './styles/learn.css'; -import { BrowserRouter, Routes, Route, Navigate, useSearchParams } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useLocation } from 'react-router-dom'; +import type { Location } from 'react-router-dom'; import { TopChrome } from './components/chrome/TopChrome'; import { FootBar } from './components/chrome/FootBar'; import { CommandPalette } from './components/palette/CommandPalette'; @@ -23,6 +24,7 @@ const ObserveTimeline = lazy(() => import('./routes/observe/timeline')); const ObserveQueue = lazy(() => import('./routes/observe/queue')); const Capture = lazy(() => import('./routes/capture/index')); const BeadRoute = lazy(() => import('./routes/bead/index')); +const BeadModal = lazy(() => import('./components/peek/BeadModal')); const DocsIndex = lazy(() => import('./routes/docs/index')); const DocsFile = lazy(() => import('./routes/docs/file')); const ArchitectureStub = lazy(() => import('./routes/architecture/index')); @@ -91,8 +93,13 @@ function DefaultRedirect() { return ; } +interface BackgroundState { backgroundLocation?: Location } + function AppShell() { const [paletteOpen, setPaletteOpen] = useState(false); + const location = useLocation(); + const state = location.state as BackgroundState | null; + const backgroundLocation = state?.backgroundLocation; useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -112,7 +119,7 @@ function AppShell() {
Loading…
}> - + } /> } /> } /> @@ -130,6 +137,13 @@ function AppShell() { } /> } /> + + {/* Drawer modal route — renders on top of whatever the main + Routes block resolved (background location for in-app + navigation; the BeadRoute deep-link fallback otherwise). */} + + } /> + diff --git a/ui/src/client/bead.ts b/ui/src/client/bead.ts index 4b2de61..491229c 100644 --- a/ui/src/client/bead.ts +++ b/ui/src/client/bead.ts @@ -39,7 +39,9 @@ export async function updateBead(id: string, patch: { title?: string; description?: string; design?: string; notes?: string; acceptance?: string; status?: string; priority?: number; assignee?: string; unassign?: boolean; - addLabel?: string; removeLabel?: string; externalRef?: string; + addLabel?: string; removeLabel?: string; + addLabels?: string[]; removeLabels?: string[]; + externalRef?: string; }): Promise { const args = ['update', id]; if (patch.title !== undefined) { args.push('--title'); args.push(patch.title); } @@ -53,6 +55,8 @@ export async function updateBead(id: string, patch: { if (patch.unassign) args.push('--unassignee'); if (patch.addLabel !== undefined) { args.push('--add-label'); args.push(patch.addLabel); } if (patch.removeLabel !== undefined) { args.push('--remove-label'); args.push(patch.removeLabel); } + for (const l of patch.addLabels ?? []) { args.push('--add-label'); args.push(l); } + for (const l of patch.removeLabels ?? []) { args.push('--remove-label'); args.push(l); } if (patch.externalRef !== undefined) args.push(`--external-ref=${patch.externalRef}`); await bdClient.fetch(args); } diff --git a/ui/src/client/fleet.ts b/ui/src/client/fleet.ts index 009369b..759ce78 100644 --- a/ui/src/client/fleet.ts +++ b/ui/src/client/fleet.ts @@ -28,7 +28,7 @@ async function fetchMoleculeDetail( signal: AbortSignal, ): Promise<{ root: Bead; children: Bead[] }> { const root = await bdClient.fetch(['show', id, '--json'], { signal }); - const childIds = (root.dependencies ?? []).map(d => d.depends_on_id); + const childIds = (root.dependencies ?? []).map(d => d.id); const children = await Promise.all( childIds.map(cid => bdClient.fetch(['show', cid, '--json'], { signal })), ); diff --git a/ui/src/client/queue.ts b/ui/src/client/queue.ts new file mode 100644 index 0000000..0e9dda6 --- /dev/null +++ b/ui/src/client/queue.ts @@ -0,0 +1,36 @@ +import { bdClient } from './bd'; +import type { Bead } from '../types'; + +export type QueueLens = 'ready' | 'ready-deferred' | 'all'; + +export interface QueueResult { + beads: Bead[]; +} + +const READY_LIMIT = 100; +const ALL_LIMIT = 200; + +export async function listQueueBeads( + workspace: string, + lens: QueueLens, + signal: AbortSignal, +): Promise { + const args = buildQueueArgs(lens); + const beads = await bdClient.fetch(args, { signal, workspace }); + return { beads }; +} + +export function buildQueueArgs(lens: QueueLens): string[] { + switch (lens) { + case 'ready': + return ['ready', `--limit=${READY_LIMIT}`, '--json']; + case 'ready-deferred': + return ['ready', `--limit=${READY_LIMIT}`, '--include-deferred', '--json']; + case 'all': + return ['list', '--status=open,in_progress', `--limit=${ALL_LIMIT}`, '--json']; + } +} + +export async function claimBead(id: string, workspace: string): Promise { + await bdClient.fetch(['update', id, '--claim'], { workspace }); +} diff --git a/ui/src/components/chrome/PeekDrawer.tsx b/ui/src/components/chrome/PeekDrawer.tsx index 5d08028..b9b754c 100644 --- a/ui/src/components/chrome/PeekDrawer.tsx +++ b/ui/src/components/chrome/PeekDrawer.tsx @@ -5,7 +5,10 @@ interface Props { kind?: 'bead' | 'mol'; id?: string; title?: string; - status?: BeadStatus; + // Status accepts any string since bd permits user-defined customs; + // the renderer falls back to a neutral icon for unknown values. + status?: string; + onClose?: () => void; children?: React.ReactNode; } @@ -15,6 +18,8 @@ const STATUS_ICON: Record = { blocked: '●', deferred: '❄', closed: '✓', + pinned: '⚲', + hooked: '⚓', }; const STATUS_CLASS: Record = { @@ -23,21 +28,45 @@ const STATUS_CLASS: Record = { blocked: 'st-blocked', deferred: 'st-deferred', closed: 'st-closed', + pinned: 'st-pinned', + hooked: 'st-hooked', }; -export function PeekDrawer({ kind = 'bead', id = '', title = '', status = 'open', children }: Props) { +function statusIcon(s?: string): string { + if (!s) return '○'; + return STATUS_ICON[s as BeadStatus] ?? '◇'; +} + +function statusClass(s?: string): string { + if (!s) return 'st-open'; + return STATUS_CLASS[s as BeadStatus] ?? 'st-open'; +} + +export function PeekDrawer({ kind = 'bead', id = '', title = '', status, onClose, children }: Props) { const navigate = useNavigate(); + const handleClose = onClose ?? (() => navigate(-1)); return (