beads-ui 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
package/app/protocol.js CHANGED
@@ -6,17 +6,10 @@
6
6
  * - Client → Server uses RequestEnvelope.
7
7
  * - Server → Client uses ReplyEnvelope.
8
8
  * - Every request is correlated by `id` in replies.
9
- * - Server can also send unsolicited events (e.g., `issues-changed`).
10
- *
11
- * Versioning
12
- * - Increment `PROTOCOL_VERSION` on breaking changes.
13
- * - Add new message types without breaking existing ones when possible.
9
+ * - Server can also send unsolicited events (e.g., subscription `snapshot`).
14
10
  */
15
11
 
16
- /** @constant {string} */
17
- export const PROTOCOL_VERSION = '1.0.0';
18
-
19
- /** @typedef {'list-issues'|'show-issue'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'subscribe-updates'|'issues-changed'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'} MessageType */
12
+ /** @typedef {'list-issues'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'|'subscribe-list'|'unsubscribe-list'|'snapshot'|'upsert'|'delete'} MessageType */
20
13
 
21
14
  /**
22
15
  * @typedef {Object} RequestEnvelope
@@ -44,20 +37,23 @@ export const PROTOCOL_VERSION = '1.0.0';
44
37
  /** @type {MessageType[]} */
45
38
  export const MESSAGE_TYPES = /** @type {const} */ ([
46
39
  'list-issues',
47
- 'show-issue',
48
40
  'update-status',
49
41
  'edit-text',
50
42
  'update-priority',
51
43
  'create-issue',
52
44
  'list-ready',
53
- 'subscribe-updates',
54
- 'issues-changed',
55
45
  'dep-add',
56
46
  'dep-remove',
57
47
  'epic-status',
58
48
  'update-assignee',
59
49
  'label-add',
60
- 'label-remove'
50
+ 'label-remove',
51
+ 'subscribe-list',
52
+ 'unsubscribe-list',
53
+ // vNext per-subscription full-issue push events
54
+ 'snapshot',
55
+ 'upsert',
56
+ 'delete'
61
57
  ]);
62
58
 
63
59
  /**
@@ -162,7 +158,7 @@ export function isReply(value) {
162
158
  return false;
163
159
  }
164
160
  if (value.ok === false) {
165
- const err = /** @type {any} */ (value).error;
161
+ const err = value.error;
166
162
  if (
167
163
  !isRecord(err) ||
168
164
  typeof err.code !== 'string' ||
package/app/protocol.md CHANGED
@@ -1,4 +1,12 @@
1
- # beads-ui WebSocket Protocol (v1.0.0)
1
+ # beads-ui WebSocket Protocol (v2.0.0)
2
+
3
+ Note (2025-10-26)
4
+
5
+ - The server no longer implements legacy read RPCs `list-issues` and
6
+ `epic-status`. Clients must use the push-only protocol described in
7
+ `docs/protocol/issues-push-v2.md` (subscribe-list with per-subscription events
8
+ snapshot/upsert/delete). The shapes below are retained for historical
9
+ reference of v1.
2
10
 
3
11
  This document defines the JSON messages exchanged between the browser client and
4
12
  the local server.
@@ -13,25 +21,24 @@ the local server.
13
21
  - ReplyEnvelope:
14
22
  `{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
15
23
 
16
- Server may send unsolicited events (e.g., `issues-changed`) using the
17
- ReplyEnvelope shape with `ok: true` and a generated `id`.
24
+ Server may send unsolicited events (e.g., subscription
25
+ `snapshot`/`upsert`/`delete`) using the ReplyEnvelope shape with `ok: true` and
26
+ a generated `id`.
18
27
 
19
28
  ## Message Types
20
29
 
21
- - `list-issues` payload: `{ filters?: { status?: string, priority?: number } }`
22
- - `show-issue` payload: `{ id: string }`
30
+ - Removed in v2: `list-issues` (use subscriptions + push stores)
23
31
  - `update-status` payload:
24
32
  `{ id: string, status: 'open'|'in_progress'|'closed' }`
25
33
  - `edit-text` payload:
26
- `{ id: string, field: 'title'|'description'|'acceptance', value: string }`
27
- - Note: `description` edits are rejected by the server (unsupported by `bd`).
34
+ `{ id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }`
28
35
  - `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
29
36
  - `create-issue` payload:
30
37
  `{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
31
38
  - `list-ready` payload: `{}`
32
- - `subscribe-updates` payload: `{}` (server responds with `ok` and begins
33
- emitting events)
34
- - `issues-changed` payload: `{ ts: number, hint?: { ids?: string[] } }`
39
+ - Removed in v2: `subscribe-updates` and the `issues-changed` event. All list
40
+ and detail updates flow via per-subscription push envelopes
41
+ (`snapshot`/`upsert`/`delete`).
35
42
  - `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
36
43
  depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
37
44
  issue for `view_id` (or `a` when omitted).
@@ -41,11 +48,11 @@ ReplyEnvelope shape with `ok: true` and a generated `id`.
41
48
 
42
49
  ## Mapping to `bd` CLI
43
50
 
44
- - `list-issues` → `bd list --json [--status <s>] [--priority <n>]`
45
- - `show-issue` → `bd show <id> --json`
51
+ - Removed in v2: `list-issues` → use subscriptions and push
52
+ (`docs/protocol/issues-push-v2.md`)
46
53
  - `update-status` → `bd update <id> --status <status>`
47
- - `edit-text` → `bd update <id> --title <t>` or `--acceptance-criteria <a>`
48
- - `description` has no CLI flag; server responds with an error
54
+ - `edit-text` → `bd update <id> --title <t>` or `--description <d>` or
55
+ `--acceptance-criteria <a>` or `--notes <n>` or `--design <z>`
49
56
  - `update-priority` → `bd update <id> --priority <n>`
50
57
  - `create-issue` → `bd create "title" -t <type> -p <prio> -d "desc"`
51
58
  - `list-ready` → `bd ready --json`
@@ -57,8 +64,3 @@ Errors follow the shape `{ code, message, details? }`. Common codes:
57
64
  - `bad_request` – malformed payload or unknown type
58
65
  - `not_found` – entity not found (e.g., issue id)
59
66
  - `bd_error` – underlying `bd` command failed
60
-
61
- ## Versioning
62
-
63
- Breaking changes to shapes or semantics increment `PROTOCOL_VERSION` in
64
- `app/protocol.js`.
package/app/router.js CHANGED
@@ -1,14 +1,31 @@
1
+ import { issueHashFor } from './utils/issue-url.js';
2
+
1
3
  /**
2
4
  * Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
3
5
  */
4
6
 
5
7
  /**
6
8
  * Parse an application hash and extract the selected issue id.
9
+ * Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
10
+ * "#/issue/<id>" which we will rewrite to the canonical form.
7
11
  * @param {string} hash
8
12
  * @returns {string | null}
9
13
  */
10
14
  export function parseHash(hash) {
11
- const m = /^#\/issue\/([^\s?#]+)/.exec(hash || '');
15
+ const h = String(hash || '');
16
+ // Extract the fragment sans leading '#'
17
+ const frag = h.startsWith('#') ? h.slice(1) : h;
18
+ const qIndex = frag.indexOf('?');
19
+ const query = qIndex >= 0 ? frag.slice(qIndex + 1) : '';
20
+ if (query) {
21
+ const params = new URLSearchParams(query);
22
+ const id = params.get('issue');
23
+ if (id) {
24
+ return decodeURIComponent(id);
25
+ }
26
+ }
27
+ // Legacy pattern: #/issue/<id>
28
+ const m = /^\/issue\/([^\s?#]+)/.exec(frag);
12
29
  return m && m[1] ? decodeURIComponent(m[1]) : null;
13
30
  }
14
31
 
@@ -30,18 +47,26 @@ export function parseView(hash) {
30
47
  }
31
48
 
32
49
  /**
33
- * Create and start the hash router.
34
50
  * @param {{ getState: () => any, setState: (patch: any) => void }} store
35
- * @returns {{ start: () => void, stop: () => void, gotoIssue: (id: string) => void, gotoView: (v: 'issues'|'epics'|'board') => void }}
36
51
  */
37
52
  export function createHashRouter(store) {
38
53
  /** @type {(ev?: HashChangeEvent) => any} */
39
54
  const onHashChange = () => {
40
55
  const hash = window.location.hash || '';
56
+ // Rewrite legacy #/issue/<id> to canonical #/issues?issue=<id>
57
+ const legacyMatch = /^#\/issue\/([^\s?#]+)/.exec(hash);
58
+ if (legacyMatch && legacyMatch[1]) {
59
+ const id = decodeURIComponent(legacyMatch[1]);
60
+ // Update state immediately for consumers expecting sync selection
61
+ store.setState({ selected_id: id, view: 'issues' });
62
+ const next = `#/issues?issue=${encodeURIComponent(id)}`;
63
+ if (window.location.hash !== next) {
64
+ window.location.hash = next;
65
+ return; // will trigger handler again
66
+ }
67
+ }
41
68
  const id = parseHash(hash);
42
- // Preserve current view when navigating to a detail route so tabs remain stable
43
- const current = store.getState ? store.getState() : { view: 'issues' };
44
- const view = id ? current.view || 'issues' : parseView(hash);
69
+ const view = parseView(hash);
45
70
  store.setState({ selected_id: id, view });
46
71
  };
47
72
 
@@ -53,21 +78,32 @@ export function createHashRouter(store) {
53
78
  stop() {
54
79
  window.removeEventListener('hashchange', onHashChange);
55
80
  },
81
+ /**
82
+ * @param {string} id
83
+ */
56
84
  gotoIssue(id) {
57
- const next = `#/issue/${encodeURIComponent(id)}`;
85
+ // Keep current view in hash and append issue param via helper
86
+ const s = store.getState ? store.getState() : { view: 'issues' };
87
+ const view = s.view || 'issues';
88
+ const next = issueHashFor(view, id);
58
89
  if (window.location.hash !== next) {
59
90
  window.location.hash = next;
60
91
  } else {
61
92
  // Force state update even if hash is the same
62
- store.setState({ selected_id: id, view: 'issues' });
93
+ store.setState({ selected_id: id, view });
63
94
  }
64
95
  },
65
96
  /**
66
97
  * Navigate to a top-level view.
67
98
  * @param {'issues'|'epics'|'board'} view
68
99
  */
100
+ /**
101
+ * @param {'issues'|'epics'|'board'} view
102
+ */
69
103
  gotoView(view) {
70
- const next = `#/${view}`;
104
+ const s = store.getState ? store.getState() : { selected_id: null };
105
+ const id = s.selected_id;
106
+ const next = id ? issueHashFor(view, id) : `#/${view}`;
71
107
  if (window.location.hash !== next) {
72
108
  window.location.hash = next;
73
109
  } else {
package/app/state.js CHANGED
@@ -15,7 +15,15 @@
15
15
  */
16
16
 
17
17
  /**
18
- * @typedef {{ selected_id: string | null, view: ViewName, filters: Filters }} AppState
18
+ * @typedef {'today'|'3'|'7'} ClosedFilter
19
+ */
20
+
21
+ /**
22
+ * @typedef {{ closed_filter: ClosedFilter }} BoardState
23
+ */
24
+
25
+ /**
26
+ * @typedef {{ selected_id: string | null, view: ViewName, filters: Filters, board: BoardState }} AppState
19
27
  */
20
28
 
21
29
  /**
@@ -26,15 +34,21 @@
26
34
  export function createStore(initial = {}) {
27
35
  /** @type {AppState} */
28
36
  let state = {
29
- selected_id: /** @type {any} */ (initial).selected_id ?? null,
30
- view: /** @type {any} */ (initial).view ?? 'issues',
37
+ selected_id: initial.selected_id ?? null,
38
+ view: initial.view ?? 'issues',
31
39
  filters: {
32
- status: /** @type {any} */ (initial).filters?.status ?? 'all',
33
- search: /** @type {any} */ (initial).filters?.search ?? '',
40
+ status: initial.filters?.status ?? 'all',
41
+ search: initial.filters?.search ?? '',
34
42
  type:
35
- typeof (/** @type {any} */ (initial).filters?.type) === 'string'
36
- ? /** @type {any} */ (initial).filters?.type
37
- : ''
43
+ typeof initial.filters?.type === 'string' ? initial.filters?.type : ''
44
+ },
45
+ board: {
46
+ closed_filter:
47
+ initial.board?.closed_filter === '3' ||
48
+ initial.board?.closed_filter === '7' ||
49
+ initial.board?.closed_filter === 'today'
50
+ ? initial.board?.closed_filter
51
+ : 'today'
38
52
  }
39
53
  };
40
54
 
@@ -57,14 +71,15 @@ export function createStore(initial = {}) {
57
71
  },
58
72
  /**
59
73
  * Update state. Nested filters can be partial.
60
- * @param {{ selected_id?: string | null, filters?: Partial<Filters> }} patch
74
+ * @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
61
75
  */
62
76
  setState(patch) {
63
77
  /** @type {AppState} */
64
78
  const next = {
65
79
  ...state,
66
80
  ...patch,
67
- filters: { ...state.filters, ...(patch.filters || {}) }
81
+ filters: { ...state.filters, ...(patch.filters || {}) },
82
+ board: { ...state.board, ...(patch.board || {}) }
68
83
  };
69
84
  // Avoid emitting if nothing changed (shallow compare)
70
85
  if (
@@ -72,7 +87,8 @@ export function createStore(initial = {}) {
72
87
  next.view === state.view &&
73
88
  next.filters.status === state.filters.status &&
74
89
  next.filters.search === state.filters.search &&
75
- next.filters.type === state.filters.type
90
+ next.filters.type === state.filters.type &&
91
+ next.board.closed_filter === state.board.closed_filter
76
92
  ) {
77
93
  return;
78
94
  }