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.
- package/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- 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., `
|
|
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
|
-
/** @
|
|
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 =
|
|
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 (
|
|
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.,
|
|
17
|
-
ReplyEnvelope shape with `ok: true` and
|
|
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`
|
|
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`
|
|
33
|
-
|
|
34
|
-
|
|
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` →
|
|
45
|
-
|
|
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 `--
|
|
48
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
30
|
-
view:
|
|
37
|
+
selected_id: initial.selected_id ?? null,
|
|
38
|
+
view: initial.view ?? 'issues',
|
|
31
39
|
filters: {
|
|
32
|
-
status:
|
|
33
|
-
search:
|
|
40
|
+
status: initial.filters?.status ?? 'all',
|
|
41
|
+
search: initial.filters?.search ?? '',
|
|
34
42
|
type:
|
|
35
|
-
typeof
|
|
36
|
-
|
|
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
|
}
|