From 1a6c8b2697523c05bfd9d745d6c09ddcc6810adc Mon Sep 17 00:00:00 2001 From: cwalv Date: Tue, 28 Apr 2026 04:29:24 +0000 Subject: [PATCH 1/6] feat(proto): v1 read-side schema + codegen pipeline + UI types refit (fo-zsazt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Decision 1 of architecture-decisions.md: define bd's wire schema in protobuf, generate TS types from .proto, treat the proto as the canonical bd interface contract. Read-side only for v1; write-side deferred to v2. ## Proto schema (proto/beads/v1/) - types.proto: Bead (with reserved field-number ranges per substrate), Dependency, Comment, Event, Workspace. - service.proto: BeadsService — List, Show, Ready, ListWorkspaces, GetFormulaSchema, ListFormulas (read-side). - formula.proto: FormulaSchema, FormulaSchemaField, FormulaEntry. - Status / type / dep_type are `string`, not proto enums (bd supports user-configured customs). Canonical values listed in field comments; renderers narrow on those. - Field numbers reserved liberally to leave room for v2 additions. - JSON names set via `[json_name = "..."]` to match bd's existing snake_case wire format — protojson `*Json` types align with `bd-server`'s output without a translation layer. ## Codegen pipeline - buf v2 config (proto/buf.yaml): STANDARD lint, WIRE_JSON breaking. - proto/buf.gen.yaml: TS via @bufbuild/protoc-gen-es, output to ui/src/gen/. Default codegen target. - proto/buf.gen.go.yaml: Go via protoc-gen-go, opt-in (no Go consumer in this repo yet — bd-server lives elsewhere). - ui/package.json scripts: postinstall + pre* lifecycle hooks regen on every entry point (build, test, test:watch, dev, typecheck). Fresh clones bootstrap on `npm install`; proto edits picked up automatically by every subsequent script. - Generated code is .gitignored — never committed, always built fresh. Avoids drift / stale-gen errors. ## UI types refit (ui/src/types/index.ts) Replaced hand-rolled type union with proto-backed aliases. The previous file truncated bd's surface in several places; the refit broadens to match handbook reference: - BeadStatus: +pinned, +hooked (was 5 of 7 canonical statuses). - BeadType: +decision, +story, +milestone, +spike, +event (was missing 4 built-ins; included some customs as if built-in). - DepType: full 20-entry surface (was 7 of 20). - Bead: ~25 previously-missing schema fields now visible — gate fields, owner, started_at/closed_at/defer_until/due_at, source_formula provenance, mol_type/work_type, ephemeral flags, epic-progress rollup, etc. ## Call sites swept Four consumers updated to the new wire shape: - client/fleet.ts: dependency edges read embedded `Bead.id` instead of the non-existent `BeadDependency.depends_on_id`. - lib/graph-walk.ts: same — dependency edges are full nested beads with `dependency_type`, mirroring `IssueDetails.Dependencies` Go struct (`[]*IssueWithDependencyMetadata`). - components/peek/IssuePeekBody.tsx: dep rendering, comment.text (was `body`), event.event_type/actor/comment (was kind/author/message). - routes/observe/graph.tsx: BeadStatus cast at narrowing sites. - components/chrome/PeekDrawer.tsx + observe/GraphFilterRail.tsx: pinned/hooked status icons + colors. ## Side effect: fixed four latent wire-format bugs The pre-refit code referenced fields bd never emits — it only worked against test mocks. Refit aligns to bd's actual `--json` output: - Comment.body → text (bd: `Text string \`json:"text"\``) - Event.kind/author/message → event_type/actor/comment - Dep edges: {depends_on_id, type} → embedded Bead with dependency_type - Workspace.reachable now required (was missing in prior tests) ## Tests All 183 UI tests pass against the new types. Test fixtures updated to populate the now-required Bead fields (id, title, status, priority, type, created_at, updated_at) and use the embedded-bead dep shape. --- proto/.gitignore | 1 + proto/beads/v1/formula.proto | 55 ++++ proto/beads/v1/service.proto | 162 ++++++++++++ proto/beads/v1/types.proto | 248 ++++++++++++++++++ proto/buf.gen.go.yaml | 14 + proto/buf.gen.yaml | 15 ++ proto/buf.yaml | 12 + ui/.gitignore | 2 + ui/package-lock.json | 217 +++++++++++++++ ui/package.json | 16 +- ui/src/client/fleet.ts | 2 +- ui/src/components/chrome/PeekDrawer.tsx | 4 + ui/src/components/observe/GraphFilterRail.tsx | 9 +- ui/src/components/peek/IssuePeekBody.tsx | 28 +- ui/src/lib/graph-layout.ts | 4 + ui/src/lib/graph-walk.ts | 10 +- ui/src/routes/observe/graph.tsx | 4 +- ui/src/types/index.ts | 183 ++++++++++--- ui/tests/GraphFilterRail.test.tsx | 12 +- ui/tests/GraphNode.test.tsx | 3 + ui/tests/IssuePeekBody.test.tsx | 24 +- ui/tests/WorkspaceSwitcher.test.tsx | 4 +- ui/tests/graph-layout.test.ts | 11 +- ui/tests/graph-walk.test.ts | 37 ++- ui/tests/molecule-agg.test.ts | 4 + ui/tests/routes/author/browse.test.tsx | 4 +- ui/tests/routes/capture/index.test.tsx | 4 +- ui/tests/routes/observe/fleet.test.tsx | 4 +- 28 files changed, 1009 insertions(+), 84 deletions(-) create mode 100644 proto/.gitignore create mode 100644 proto/beads/v1/formula.proto create mode 100644 proto/beads/v1/service.proto create mode 100644 proto/beads/v1/types.proto create mode 100644 proto/buf.gen.go.yaml create mode 100644 proto/buf.gen.yaml create mode 100644 proto/buf.yaml 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..3ccbce3 --- /dev/null +++ b/proto/beads/v1/types.proto @@ -0,0 +1,248 @@ +// 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. + string type = 22; + 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..3144d93 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "beads-ui", "dependencies": { + "@bufbuild/protobuf": "^2.2.0", "@panzoom/panzoom": "^4.6.2", "@types/dagre": "^0.7.54", "dagre": "^0.8.5", @@ -15,6 +16,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 +351,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 +1828,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/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/components/chrome/PeekDrawer.tsx b/ui/src/components/chrome/PeekDrawer.tsx index 5d08028..100e4e3 100644 --- a/ui/src/components/chrome/PeekDrawer.tsx +++ b/ui/src/components/chrome/PeekDrawer.tsx @@ -15,6 +15,8 @@ const STATUS_ICON: Record = { blocked: '●', deferred: '❄', closed: '✓', + pinned: '⚲', + hooked: '⚓', }; const STATUS_CLASS: Record = { @@ -23,6 +25,8 @@ 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) { diff --git a/ui/src/components/observe/GraphFilterRail.tsx b/ui/src/components/observe/GraphFilterRail.tsx index fd22f7a..73d30e7 100644 --- a/ui/src/components/observe/GraphFilterRail.tsx +++ b/ui/src/components/observe/GraphFilterRail.tsx @@ -8,7 +8,7 @@ interface Props { onToggleType: (t: string) => void; } -const ALL_STATUSES: BeadStatus[] = ['open', 'in_progress', 'blocked', 'deferred', 'closed']; +const ALL_STATUSES: BeadStatus[] = ['open', 'in_progress', 'blocked', 'deferred', 'closed', 'pinned', 'hooked']; const STATUS_COLOR: Record = { open: 'var(--mute)', @@ -16,6 +16,8 @@ const STATUS_COLOR: Record = { blocked: 'var(--danger)', deferred: 'var(--ink-3)', closed: 'var(--mute-2)', + pinned: 'var(--ink-3)', + hooked: 'var(--accent)', }; const STATUS_LABEL: Record = { @@ -24,6 +26,8 @@ const STATUS_LABEL: Record = { blocked: 'blocked', deferred: 'deferred', closed: 'closed', + pinned: 'pinned', + hooked: 'hooked', }; function SectionLabel({ children }: { children: React.ReactNode }) { @@ -47,7 +51,8 @@ export function GraphFilterRail({ nodes, hiddenStatuses, hiddenTypes, onToggleSt const typesPresent = new Set(); for (const node of nodes) { - statusCounts.set(node.bead.status, (statusCounts.get(node.bead.status) ?? 0) + 1); + const s = node.bead.status as BeadStatus; + statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1); if (node.bead.type) typesPresent.add(node.bead.type); } diff --git a/ui/src/components/peek/IssuePeekBody.tsx b/ui/src/components/peek/IssuePeekBody.tsx index e1df2ea..843c83a 100644 --- a/ui/src/components/peek/IssuePeekBody.tsx +++ b/ui/src/components/peek/IssuePeekBody.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import type { Bead, BeadDependency, BeadDependent, DepType } from '../../types'; +import type { Bead, DepType } from '../../types'; import { getBead, updateBead, addComment, addDep, removeDep } from '../../client/bead'; interface Props { @@ -397,12 +397,12 @@ export function IssuePeekBody({ beadId, onClose: _onClose, onNodePatch }: Props) {(bead.dependencies ?? []).length === 0 && (
none
)} - {(bead.dependencies ?? []).map((dep: BeadDependency) => ( -
- {dep.type} - {dep.depends_on_id} + {(bead.dependencies ?? []).map(dep => ( +
+ {dep.dependency_type} + {dep.id}
-
{c.body}
+
{c.text}
))}
@@ -498,11 +498,11 @@ export function IssuePeekBody({ beadId, onClose: _onClose, onNodePatch }: Props) {(bead.events ?? []).map(ev => (
- {ev.kind} - {ev.author && {ev.author}} + {ev.event_type} + {ev.actor && {ev.actor}} {ev.created_at && {ev.created_at}}
- {ev.message &&
{ev.message}
} + {ev.comment &&
{ev.comment}
}
))}
diff --git a/ui/src/lib/graph-layout.ts b/ui/src/lib/graph-layout.ts index 1e3099d..509624c 100644 --- a/ui/src/lib/graph-layout.ts +++ b/ui/src/lib/graph-layout.ts @@ -62,6 +62,10 @@ export function layoutGraph(beads: Map, rawEdges: RawEdge[] id, title: id, status: 'open', + priority: 2, + type: 'task', + created_at: '', + updated_at: '', }; const actualBead = bead ?? ghostBead; diff --git a/ui/src/lib/graph-walk.ts b/ui/src/lib/graph-walk.ts index 051b550..e30dea5 100644 --- a/ui/src/lib/graph-walk.ts +++ b/ui/src/lib/graph-walk.ts @@ -36,13 +36,15 @@ export async function walkMoleculeGraph( for (const id of frontier) { const bead = beads.get(id); if (!bead) continue; + // bd show populates dependencies / dependents as full nested beads + // tagged with `dependency_type` (the edge kind connecting to `id`). for (const dep of bead.dependencies ?? []) { - addEdge(dep.depends_on_id, id, dep.type); - if (!beads.has(dep.depends_on_id)) nextFrontier.add(dep.depends_on_id); + addEdge(dep.id, id, (dep.dependency_type ?? 'related') as DepType); + if (!beads.has(dep.id)) nextFrontier.add(dep.id); } for (const dep of bead.dependents ?? []) { - addEdge(id, dep.issue_id, dep.type); - if (!beads.has(dep.issue_id)) nextFrontier.add(dep.issue_id); + addEdge(id, dep.id, (dep.dependency_type ?? 'related') as DepType); + if (!beads.has(dep.id)) nextFrontier.add(dep.id); } } frontier = [...nextFrontier]; diff --git a/ui/src/routes/observe/graph.tsx b/ui/src/routes/observe/graph.tsx index 3c7fe16..7e59f33 100644 --- a/ui/src/routes/observe/graph.tsx +++ b/ui/src/routes/observe/graph.tsx @@ -26,7 +26,7 @@ export default function ObserveGraph() { const [hiddenTypes, setHiddenTypes] = useState>(new Set()); const visibleNodes = graph?.nodes.filter(n => - !hiddenStatuses.has(n.bead.status) && + !hiddenStatuses.has(n.bead.status as BeadStatus) && (!n.bead.type || !hiddenTypes.has(n.bead.type)) ) ?? []; @@ -138,7 +138,7 @@ export default function ObserveGraph() { kind="bead" id={peekId} title={peekNode?.bead.title ?? peekId} - status={peekNode?.bead.status ?? 'open'} + status={(peekNode?.bead.status ?? 'open') as BeadStatus} > diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 7431371..1137ed2 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -1,19 +1,96 @@ -export type Destination = 'author' | 'observe' | 'capture' | 'docs'; +// UI-facing type surface. Proto-defined messages re-exported under the +// names the rest of the codebase already uses; UI-only types defined +// inline below. +// +// The proto schema (`proto/beads/v1/`) is the canonical bd interface +// contract. JSON wire types (`*Json`) match bd-server's snake_case wire +// format directly via per-field `[json_name = ...]` annotations, so cast +// from `fetch().json()` is sound without a translation layer. +// +// See projects/foundations/docs/beads-ui/architecture-decisions.md +// (Decision 1) for the rationale. + +import type { + BeadJson, + CommentJson, + DependencyJson, + EventJson, + WorkspaceJson, +} from '../gen/beads/v1/types_pb.js'; +import type { + FormulaEntryJson, + FormulaSchemaFieldJson, + FormulaSchemaJson, +} from '../gen/beads/v1/formula_pb.js'; + +// ===== Proto-backed types ===== +// +// protojson treats every proto3 field as optional in JSON since absent +// fields receive proto's default values on the wire. bd's actual +// behavior populates a small set of fields on every row (id, title, +// status, priority, type, created_at, updated_at). The aliases below +// assert that contract for UI ergonomics; runtime parsing should still +// guard against malformed responses. -export interface Workspace { +export type Bead = Omit< + BeadJson, + | 'id' + | 'title' + | 'status' + | 'priority' + | 'type' + | 'created_at' + | 'updated_at' + | 'dependencies' + | 'dependents' + | 'comments' +> & { + id: string; + title: string; + status: string; + priority: number; + type: string; + created_at: NonNullable; + updated_at: NonNullable; + // bd show populates dependencies / dependents as full nested beads + // tagged with `dependency_type`. Comments are full Comment rows. + dependencies?: Bead[]; + dependents?: Bead[]; + comments?: Comment[]; + // UI-side extension: proto Bead does not include event-table rows in v1 + // (bd-server's `bd show --json` does not populate them). The field is + // kept here so the events tab can render once a future bd-server + // surface attaches them. Always `undefined` against the current wire. + events?: Event[]; +}; + +export type Comment = Omit & { + id: string; + issue_id: string; + author: string; + text: string; + created_at: NonNullable; +}; + +export type Dependency = DependencyJson; +export type Event = EventJson; +export type Workspace = Omit & { name: string; path: string; - description?: string; - color?: string; -} + reachable: boolean; +}; +export type FormulaSchema = FormulaSchemaJson; +export type FormulaSchemaField = FormulaSchemaFieldJson; +export type FormulaEntry = FormulaEntryJson; + +// ===== UI / transport-only types ===== + +export type Destination = 'author' | 'observe' | 'capture' | 'docs'; export interface WorkspacesResponse { workspaces: Workspace[]; } -export type BeadStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed'; -export type BeadType = 'bug' | 'feature' | 'task' | 'epic' | 'chore' | 'message' | 'merge-request' | 'molecule' | 'gate' | 'agent' | 'role' | 'convoy'; - export interface BdError { kind: 'network' | 'server' | 'parse'; message: string; @@ -26,34 +103,72 @@ export interface BdResponse { error?: BdError; } -export type DepType = 'tracks' | 'blocks' | 'parent-child' | 'waits-for' | 'conditional-blocks' | 'related' | 'discovered-from'; +// ===== Canonical value tables (string narrowings) ===== +// +// The proto exposes status / type / dep_type as bare `string` because bd +// supports user-defined customs. UI components that switch on known values +// narrow to these unions; unknown values pass through with neutral render. -export interface BeadDependency { depends_on_id: string; type: DepType; } -export interface BeadDependent { issue_id: string; type: DepType; } -export interface BeadComment { id: string; body: string; author?: string; created_at?: string; } -export interface BeadEvent { id: string; kind: string; message?: string; author?: string; created_at?: string; } +export type BeadStatus = + | 'open' + | 'in_progress' + | 'blocked' + | 'deferred' + | 'closed' + | 'pinned' + | 'hooked'; -export interface Bead { - id: string; - title: string; - description?: string; - design?: string; - acceptance_criteria?: string; - notes?: string; - status: BeadStatus; - type?: BeadType; - priority?: number; - assignee?: string; - labels?: string[]; - external_ref?: string; - metadata?: Record; - dependencies?: BeadDependency[]; - dependents?: BeadDependent[]; - comments?: BeadComment[]; - events?: BeadEvent[]; - created_at?: string; - updated_at?: string; -} +export type BeadType = + | 'bug' + | 'feature' + | 'task' + | 'epic' + | 'chore' + | 'decision' + | 'message' + | 'molecule' + | 'spike' + | 'story' + | 'milestone' + | 'event' + // Removed-from-built-in but commonly seen as customs: + | 'gate' + | 'convoy' + | 'merge-request' + | 'slot' + | 'agent' + | 'role' + | 'rig'; + +export type DepType = + // Workflow (affect ready-work calc) + | 'blocks' + | 'parent-child' + | 'conditional-blocks' + | 'waits-for' + // Association + | 'related' + | 'discovered-from' + // Graph link + | 'replies-to' + | 'relates-to' + | 'duplicates' + | 'supersedes' + // Entity + | 'authored-by' + | 'assigned-to' + | 'approved-by' + | 'attests' + // Convoy / cross-project + | 'tracks' + // Reference + | 'until' + | 'caused-by' + | 'validates' + // Delegation + | 'delegated-from'; + +// ===== Layout types (UI-side molecule graph rendering) ===== export interface LayoutNode { id: string; diff --git a/ui/tests/GraphFilterRail.test.tsx b/ui/tests/GraphFilterRail.test.tsx index 66c70c6..bfe6a00 100644 --- a/ui/tests/GraphFilterRail.test.tsx +++ b/ui/tests/GraphFilterRail.test.tsx @@ -3,10 +3,18 @@ import { describe, it, expect, vi } from 'vitest'; import { GraphFilterRail } from '../src/components/observe/GraphFilterRail'; import type { LayoutNode, Bead } from '../src/types'; -function makeNode(id: string, status: Bead['status'], type?: Bead['type']): LayoutNode { +function makeNode(id: string, status: Bead['status'], type: Bead['type'] = 'task'): LayoutNode { return { id, - bead: { id, title: `Bead ${id}`, status, type }, + bead: { + id, + title: `Bead ${id}`, + status, + type, + priority: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, x: 0, y: 0, w: 220, h: 80, }; } diff --git a/ui/tests/GraphNode.test.tsx b/ui/tests/GraphNode.test.tsx index a92907f..ba63f5f 100644 --- a/ui/tests/GraphNode.test.tsx +++ b/ui/tests/GraphNode.test.tsx @@ -12,6 +12,9 @@ function makeNode(overrides?: Partial & { beadOverrides?: Partial ({ import { getBead } from '../src/client/bead'; +function makeNestedBead(id: string, depType: string): Bead { + return { + id, + title: `Bead ${id}`, + status: 'open', + priority: 2, + type: 'task', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + dependency_type: depType, + }; +} + const mockBead: Bead = { id: 'fo-test-1', title: 'Test Bead', description: 'A test description', status: 'open', priority: 2, + type: 'task', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', assignee: 'alice', - dependencies: [{ depends_on_id: 'fo-dep-1', type: 'tracks' }], - dependents: [{ issue_id: 'fo-child-1', type: 'blocks' }], - comments: [{ id: 'c1', body: 'Hello world', author: 'bob', created_at: '2026-01-01' }], - events: [{ id: 'e1', kind: 'status_changed', message: 'opened', author: 'alice', created_at: '2026-01-01' }], + dependencies: [makeNestedBead('fo-dep-1', 'tracks')], + dependents: [makeNestedBead('fo-child-1', 'blocks')], + comments: [{ id: 'c1', issue_id: 'fo-test-1', text: 'Hello world', author: 'bob', created_at: '2026-01-01T00:00:00Z' }], + events: [{ id: 'e1', issue_id: 'fo-test-1', event_type: 'status_changed', actor: 'alice', comment: 'opened', created_at: '2026-01-01T00:00:00Z' }], }; beforeEach(() => { diff --git a/ui/tests/WorkspaceSwitcher.test.tsx b/ui/tests/WorkspaceSwitcher.test.tsx index 496ed61..d745683 100644 --- a/ui/tests/WorkspaceSwitcher.test.tsx +++ b/ui/tests/WorkspaceSwitcher.test.tsx @@ -5,8 +5,8 @@ import { WorkspaceSwitcher } from '../src/components/switcher/WorkspaceSwitcher' import { WorkspaceContext } from '../src/hooks/useWorkspace'; import type { WorkspaceState } from '../src/hooks/useWorkspace'; -const ws1 = { name: 'fo-beads-ui', path: '/tmp/fo', description: 'primary', color: '#2f6fe8' }; -const ws2 = { name: 'demo', path: '/tmp/demo', description: 'demo', color: '#2d7a4a' }; +const ws1 = { name: 'fo-beads-ui', path: '/tmp/fo', reachable: true, description: 'primary', color: '#2f6fe8' }; +const ws2 = { name: 'demo', path: '/tmp/demo', reachable: true, description: 'demo', color: '#2d7a4a' }; function makeCtx(overrides?: Partial): WorkspaceState { return { diff --git a/ui/tests/graph-layout.test.ts b/ui/tests/graph-layout.test.ts index 9f23a80..34ee024 100644 --- a/ui/tests/graph-layout.test.ts +++ b/ui/tests/graph-layout.test.ts @@ -4,7 +4,16 @@ import type { Bead } from '../src/types'; import type { RawEdge } from '../src/lib/graph-walk'; function makeBead(id: string, overrides?: Partial): Bead { - return { id, title: `Bead ${id}`, status: 'open', ...overrides }; + return { + id, + title: `Bead ${id}`, + status: 'open', + priority: 2, + type: 'task', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, + }; } describe('layoutGraph', () => { diff --git a/ui/tests/graph-walk.test.ts b/ui/tests/graph-walk.test.ts index 194de47..d39ece8 100644 --- a/ui/tests/graph-walk.test.ts +++ b/ui/tests/graph-walk.test.ts @@ -1,17 +1,32 @@ import { describe, it, expect } from 'vitest'; import { walkMoleculeGraph } from '../src/lib/graph-walk'; -import type { Bead } from '../src/types'; +import type { Bead, DepType } from '../src/types'; function makeBead(id: string, overrides?: Partial): Bead { - return { id, title: `Bead ${id}`, status: 'open', ...overrides }; + return { + id, + title: `Bead ${id}`, + status: 'open', + priority: 2, + type: 'task', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +// Edge fixture: one nested bead in dependencies/dependents arrays mirrors +// `IssueWithDependencyMetadata` — full Bead + `dependency_type`. +function depEdge(targetId: string, type: DepType): Bead { + return makeBead(targetId, { dependency_type: type }); } describe('walkMoleculeGraph', () => { it('happy path: root with 2 deps returns 3 nodes and 2 edges', async () => { const beadA = makeBead('A', { dependencies: [ - { depends_on_id: 'B', type: 'tracks' }, - { depends_on_id: 'C', type: 'blocks' }, + depEdge('B', 'tracks'), + depEdge('C', 'blocks'), ], }); const beadB = makeBead('B'); @@ -34,8 +49,8 @@ describe('walkMoleculeGraph', () => { it('deduplication: same id referenced from multiple paths is only fetched once', async () => { let fetchCount = 0; const beadA = makeBead('A', { - dependencies: [{ depends_on_id: 'B', type: 'tracks' }], - dependents: [{ issue_id: 'B', type: 'related' }], + dependencies: [depEdge('B', 'tracks')], + dependents: [depEdge('B', 'related')], }); const beadB = makeBead('B'); @@ -52,10 +67,10 @@ describe('walkMoleculeGraph', () => { it('depth cap: at maxDepth=1, stops before fetching second-hop nodes', async () => { const beadA = makeBead('A', { - dependencies: [{ depends_on_id: 'B', type: 'tracks' }], + dependencies: [depEdge('B', 'tracks')], }); const beadB = makeBead('B', { - dependencies: [{ depends_on_id: 'C', type: 'tracks' }], + dependencies: [depEdge('C', 'tracks')], }); const beadC = makeBead('C'); @@ -75,7 +90,7 @@ describe('walkMoleculeGraph', () => { it('ghost node: fetcher throws for one id → bead is null', async () => { const beadA = makeBead('A', { - dependencies: [{ depends_on_id: 'B', type: 'tracks' }], + dependencies: [depEdge('B', 'tracks')], }); const fetcher = async (id: string) => { @@ -90,10 +105,10 @@ describe('walkMoleculeGraph', () => { it('cycle guard: A depends on B, B depends on A → no infinite loop', async () => { const beadA = makeBead('A', { - dependencies: [{ depends_on_id: 'B', type: 'tracks' }], + dependencies: [depEdge('B', 'tracks')], }); const beadB = makeBead('B', { - dependencies: [{ depends_on_id: 'A', type: 'tracks' }], + dependencies: [depEdge('A', 'tracks')], }); const fetcher = async (id: string) => { diff --git a/ui/tests/molecule-agg.test.ts b/ui/tests/molecule-agg.test.ts index 443173f..7c89dca 100644 --- a/ui/tests/molecule-agg.test.ts +++ b/ui/tests/molecule-agg.test.ts @@ -6,6 +6,10 @@ function makeBead(overrides: Partial & { id: string }): Bead { return { title: overrides.id, status: 'open', + priority: 2, + type: 'task', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', ...overrides, }; } diff --git a/ui/tests/routes/author/browse.test.tsx b/ui/tests/routes/author/browse.test.tsx index d6a6c7f..5ff30b0 100644 --- a/ui/tests/routes/author/browse.test.tsx +++ b/ui/tests/routes/author/browse.test.tsx @@ -20,8 +20,8 @@ const footerApi: FooterAPI = { content: { left: '', right: '' }, setContent: vi. function makeWorkspaceCtx(overrides: Partial = {}): WorkspaceState { return { - workspaces: [{ name: 'foundations', path: '/tmp/.beads' }], - current: { name: 'foundations', path: '/tmp/.beads' }, + workspaces: [{ name: 'foundations', path: '/tmp/.beads', reachable: true }], + current: { name: 'foundations', path: '/tmp/.beads', reachable: true }, loading: false, error: null, isStub: true, diff --git a/ui/tests/routes/capture/index.test.tsx b/ui/tests/routes/capture/index.test.tsx index 346a3d7..f3375f1 100644 --- a/ui/tests/routes/capture/index.test.tsx +++ b/ui/tests/routes/capture/index.test.tsx @@ -19,8 +19,8 @@ const footerApi: FooterAPI = { content: { left: '', right: '' }, setContent: vi. function makeWorkspaceCtx(overrides: Partial = {}): WorkspaceState { return { - workspaces: [{ name: 'fo-beads-ui', path: '/tmp/.beads', color: '#2f6fe8' }], - current: { name: 'fo-beads-ui', path: '/tmp/.beads', color: '#2f6fe8' }, + workspaces: [{ name: 'fo-beads-ui', path: '/tmp/.beads', reachable: true, color: '#2f6fe8' }], + current: { name: 'fo-beads-ui', path: '/tmp/.beads', reachable: true, color: '#2f6fe8' }, loading: false, error: null, isStub: true, diff --git a/ui/tests/routes/observe/fleet.test.tsx b/ui/tests/routes/observe/fleet.test.tsx index c0d1e2a..c2809d6 100644 --- a/ui/tests/routes/observe/fleet.test.tsx +++ b/ui/tests/routes/observe/fleet.test.tsx @@ -20,8 +20,8 @@ const footerApi: FooterAPI = { content: { left: '', right: '' }, setContent: vi. function makeWorkspaceCtx(overrides: Partial = {}): WorkspaceState { return { - workspaces: [{ name: 'foundations', path: '/tmp/.beads' }], - current: { name: 'foundations', path: '/tmp/.beads' }, + workspaces: [{ name: 'foundations', path: '/tmp/.beads', reachable: true }], + current: { name: 'foundations', path: '/tmp/.beads', reachable: true }, loading: false, error: null, isStub: true, From bb1030b44272bf93a06696b0231ccce736d52ed1 Mon Sep 17 00:00:00 2001 From: cwalv Date: Tue, 28 Apr 2026 05:26:59 +0000 Subject: [PATCH 2/6] fix(proto): Bead.type wire name should be issue_type (fo-zsazt follow-up) bd's Issue Go struct tags IssueType as `json:"issue_type,omitempty"`. The proto field stayed at name `type` without [json_name = "issue_type"], so protojson silently drops bd's `issue_type` JSON output. UI consumers that read bead.type against real bd-server traffic always saw an empty string; tests didn't catch it because they mock bdClient. Field number 22 stays the same (proto field numbers are forever); only the JSON name needs to match the wire. Generated TS/JsonShape now exposes `issue_type?: string` on BeadJson. Surfaced by fo-1vtyi worker who hit the bug while building the queue view (had to normalize `b.type ?? b.issue_type` as a workaround). Also picks up package-lock.json drift: fo-zsazt added a postinstall script (`npm run proto:generate`) which causes npm to flag hasInstallScript=true in the lock file on first install. One-line metadata update; no dep changes. Tests: 183/183 pass against the regenerated types. --- proto/beads/v1/types.proto | 6 +++++- ui/package-lock.json | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/proto/beads/v1/types.proto b/proto/beads/v1/types.proto index 3ccbce3..fcddd3a 100644 --- a/proto/beads/v1/types.proto +++ b/proto/beads/v1/types.proto @@ -57,7 +57,11 @@ message Bead { // message | molecule | spike | story | milestone | event. Removed but // commonly seen as customs: gate | convoy | merge-request | slot | // agent | role | rig. - string type = 22; + // + // 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 ===== diff --git a/ui/package-lock.json b/ui/package-lock.json index 3144d93..908921f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "beads-ui", + "hasInstallScript": true, "dependencies": { "@bufbuild/protobuf": "^2.2.0", "@panzoom/panzoom": "^4.6.2", From 4f579a1ba7ae987c302a91e9ead848587a72c5b1 Mon Sep 17 00:00:00 2001 From: cwalv Date: Tue, 28 Apr 2026 05:35:56 +0000 Subject: [PATCH 3/6] feat(peek): real-data wiring + URL-routing modal model (fo-1w8xo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation by foundations/worker-2 (session gc-wisp-d1x5). Replaces /bead/:id 'not yet wired' stub. Peek drawer now renders all proto-broadened Bead fields, opens from any source as a URL-routed modal overlay, and resolves the modal-vs-route open question from fo-zz4pz §4. ## Real-data wiring (IssuePeekBody, +275 lines) - Overview: id, title, status, type, priority, assignee, owner, labels, external_ref, plus all six lifecycle dates (created/updated/started/ closed/due/defer), close_reason, and the four body sections (description/design/acceptance/notes). - Tabs: Overview / Deps / Comments + Metadata (when present) + Events (when bead.events is populated). - Pack-aware metadata: inline METADATA_LABELS map for gc.* + the delegated_from key, friendly labels rendered with raw-JSON fallback for unknown keys. Marked for migration into fo-0qdg9 convention pack. - Edit mode adds labels (comma-separated, diffed into addLabels[]/ removeLabels[]) and external_ref. updateBead client gained the array variants. - Dep ids are clickable to chain peeks; hides Events tab when bead.events is empty (matches fo-zsazt's 'dead UI' note). ## URL routing (App.tsx, hooks/usePeek.ts, routes/bead/index.tsx, routes/observe/graph.tsx, components/peek/BeadModal.tsx) - useOpenPeek pushes /bead/:id with state.backgroundLocation; App.tsx swaps to backgroundLocation for the main Routes pass and renders a sibling modal Routes for /bead/:beadId. - close() prefers navigate(-1); deep-link close falls through to /observe/queue. - Deep links work: BeadDeepLinkUnderlay (routes/bead/index.tsx) lazy- loads ObserveQueue as the configured underlying view; modal Routes always renders the drawer when the URL matches. - open() preserves an existing backgroundLocation when chaining (so dep-row clicks keep the original underlying view). - bead-patched CustomEvent on save lets the (now decoupled) graph canvas re-sync without prop-drilling. ## Tests (IssuePeekBody.test.tsx, peek-routing.test.tsx) - 12 new tests covering field rendering, edit mode, modal open/close routing, deep-link handling, peek chaining. - 195/195 total pass against pre-bb1030b proto (fo-zsazt without the issue_type fix); rebase on bb1030b is non-conflicting. ## Notable choices (per worker review mail) - Inline METADATA_LABELS rather than waiting on convention library — short-term to give the user a useful pack-aware render today; will move to fo-0qdg9. - Fleet rows still drill into graph (unchanged); 'open from anywhere' read as the drawer mechanism being available, not a mandate to change every existing row click. References: - fo-zz4pz §4 (peek drawer real data + routing decision) - ui-review.md cross-cutting §3 + View 8 - architecture-decisions.md (Decision 3 — pack-aware metadata) --- ui/src/App.tsx | 18 +- ui/src/client/bead.ts | 6 +- ui/src/components/chrome/PeekDrawer.tsx | 35 ++- ui/src/components/peek/BeadModal.tsx | 37 +++ ui/src/components/peek/IssuePeekBody.tsx | 275 +++++++++++++++++++---- ui/src/hooks/usePeek.ts | 55 ++++- ui/src/routes/bead/index.tsx | 32 ++- ui/src/routes/observe/graph.tsx | 35 ++- ui/tests/IssuePeekBody.test.tsx | 114 +++++++++- ui/tests/peek-routing.test.tsx | 155 +++++++++++++ 10 files changed, 655 insertions(+), 107 deletions(-) create mode 100644 ui/src/components/peek/BeadModal.tsx create mode 100644 ui/tests/peek-routing.test.tsx 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/components/chrome/PeekDrawer.tsx b/ui/src/components/chrome/PeekDrawer.tsx index 100e4e3..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; } @@ -29,19 +32,41 @@ const STATUS_CLASS: Record = { 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 (