beads-ui 0.1.2 β 0.2.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 +24 -2
- package/README.md +39 -45
- package/app/data/providers.js +57 -26
- package/app/index.html +8 -0
- package/app/main.js +179 -33
- package/app/protocol.md +3 -4
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +170 -6
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/toast.js +35 -0
- package/app/views/board.js +347 -17
- package/app/views/detail.js +292 -92
- package/app/views/epics.js +2 -2
- package/app/views/issue-dialog.js +170 -0
- package/app/views/issue-row.js +9 -8
- package/app/views/list.js +85 -11
- package/app/views/new-issue-dialog.js +352 -0
- package/app/ws.js +30 -0
- package/docs/architecture.md +1 -1
- package/package.json +8 -2
- package/server/cli/commands.js +11 -3
- package/server/cli/index.js +35 -4
- package/server/cli/usage.js +1 -1
- package/server/watcher.js +3 -3
- package/server/ws.js +39 -19
- package/docs/quickstart.md +0 -142
package/CHANGES.md
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- π Add "Blocked" column to board
|
|
6
|
+
- π Support `design` in issue details
|
|
7
|
+
- π Add filter to closed column and improve sorting
|
|
8
|
+
- π Unblock issue description editing
|
|
9
|
+
- π CLI: require --open to launch browser, also on restart
|
|
10
|
+
- π Up/down/left/right keyboard navigation on board
|
|
11
|
+
- π Up/down keyboard navigation on issues list
|
|
12
|
+
- π CLI: require --open to launch browser
|
|
13
|
+
- π Make issue notes editable
|
|
14
|
+
- π Show toast on disconnect/reconnect
|
|
15
|
+
- π Support creating a new issue via "New" dialog
|
|
16
|
+
- π Copy issue IDs to clipboard
|
|
17
|
+
- π Open issue details in dialog
|
|
18
|
+
- π Remove --limit 10 when fetching closed issues
|
|
19
|
+
- β¨ Events: coalesce issues-changed to avoid redundant full refresh
|
|
20
|
+
- β¨ Update issues
|
|
21
|
+
- β¨ Align callback function naming
|
|
22
|
+
- π Improve README
|
|
23
|
+
- π Add package description, homepage and repo
|
|
24
|
+
|
|
3
25
|
## 0.1.2
|
|
4
26
|
|
|
5
|
-
- π¦ Specify files to package
|
|
27
|
+
- π¦ Specify files to package
|
|
6
28
|
|
|
7
29
|
## 0.1.1
|
|
8
30
|
|
|
9
|
-
- π Make screenshot src absolute and add license
|
|
31
|
+
- π Make screenshot src absolute and add license
|
|
10
32
|
|
|
11
33
|
## 0.1.0
|
|
12
34
|
|
package/README.md
CHANGED
|
@@ -1,64 +1,63 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
Beads UI
|
|
3
|
+
</h1>
|
|
4
|
+
<p align="center">
|
|
5
|
+
<b>Localβfirst UI for the <code>bd</code> CLI β <a href="https://github.com/steveyegge/beads">Beads</a></b>
|
|
6
|
+
</p>
|
|
7
|
+
<div align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/beads-ui"><img src="https://img.shields.io/npm/v/beads-ui.svg" alt="npm Version"></a>
|
|
9
|
+
<a href="https://semver.org"><img src="https://img.shields.io/:semver-%E2%9C%93-blue.svg" alt="SemVer"></a>
|
|
10
|
+
<a href="https://github.com/mantoni/beads-ui/actions/worflows/ci.yml"><img src="https://github.com/mantoni/eslint_d.js/actions/workflows/ci.yml/badge.svg" alt="Build Status"></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/npm/l/eslint_d.svg" alt="MIT License"></a>
|
|
12
|
+
<br>
|
|
13
|
+
<br>
|
|
14
|
+
</div>
|
|
2
15
|
|
|
3
|
-
|
|
4
|
-
tracker.
|
|
16
|
+
## Features
|
|
5
17
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
- β¨ **Zero setup** β just run `bdui start`
|
|
19
|
+
- π¨ **Beautiful design** β Responsive and dark mode support
|
|
20
|
+
- β¨οΈ **Keyboard navigation** β Navigate and edit without touching the mouse
|
|
21
|
+
- β‘ **Live updates** β Monitors the beads database for changes
|
|
22
|
+
- π **Issues view** β Filter and search issues, edit inline
|
|
23
|
+
- β°οΈ **Epics view** β Show progress per epic, expand rows, edit inline
|
|
24
|
+
- π **Board view** β Open / Blocked / Ready / In progress / Closed columns
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
## Setup
|
|
12
27
|
|
|
13
|
-
|
|
28
|
+
```sh
|
|
29
|
+
npm i -g beads-ui
|
|
30
|
+
bdui start --open
|
|
31
|
+
```
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
- Epics view grouped by epic (from `bd epic status --json`) with expandable rows
|
|
17
|
-
- Board view with Ready / In progress / Closed columns
|
|
18
|
-
- Deep links for navigation; state persists across reloads
|
|
19
|
-
- Live updates via FS watch + WebSocket; optimistic UI with rollbacks on error
|
|
20
|
-
- Dark theme toggle, saved per user
|
|
21
|
-
- Local CLI helper `bdui` to daemonize the server and open your browser
|
|
33
|
+
See `bdui --help` for options.
|
|
22
34
|
|
|
23
35
|
## Screenshots
|
|
24
36
|
|
|
25
|
-
Issues
|
|
37
|
+
**Issues**
|
|
26
38
|
|
|
27
39
|

|
|
28
40
|
|
|
29
|
-
Epics
|
|
41
|
+
**Epics**
|
|
30
42
|
|
|
31
43
|

|
|
32
44
|
|
|
33
|
-
Board
|
|
45
|
+
**Board**
|
|
34
46
|
|
|
35
47
|

|
|
36
48
|
|
|
37
|
-
##
|
|
38
|
-
|
|
39
|
-
Prerequisites:
|
|
40
|
-
|
|
41
|
-
- Node.js >= 22
|
|
42
|
-
- `bd` CLI on your PATH (or set `BD_BIN=/path/to/bd`)
|
|
43
|
-
|
|
44
|
-
Install and start:
|
|
45
|
-
|
|
46
|
-
```sh
|
|
47
|
-
npm install -g beads-ui
|
|
48
|
-
bdui start
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
See `bdui --help` for options.
|
|
52
|
-
|
|
53
|
-
Environment variables:
|
|
49
|
+
## Environment variables
|
|
54
50
|
|
|
51
|
+
- `BD_BIN`: path to the `bd` binary.
|
|
55
52
|
- `BDUI_RUNTIME_DIR`: override runtime directory for PID/logs. Defaults to
|
|
56
53
|
`$XDG_RUNTIME_DIR/beads-ui` or the system temp dir.
|
|
57
|
-
- `BDUI_NO_OPEN=1`: disable opening the default browser on `start`.
|
|
54
|
+
- `BDUI_NO_OPEN=1`: disable opening the default browser on `start`. Note:
|
|
55
|
+
Opening the browser is disabled by default; use `--open` to explicitly launch
|
|
56
|
+
the browser, which overrides this env var.
|
|
58
57
|
- `PORT`: overrides the listen port (default `3000`). The server binds to
|
|
59
58
|
`127.0.0.1`.
|
|
60
59
|
|
|
61
|
-
Platform notes
|
|
60
|
+
## Platform notes
|
|
62
61
|
|
|
63
62
|
- macOS/Linux are fully supported. On Windows, the CLI uses `cmd /c start` to
|
|
64
63
|
open URLs and relies on Nodeβs `process.kill` semantics for stopping the
|
|
@@ -66,13 +65,8 @@ Platform notes:
|
|
|
66
65
|
|
|
67
66
|
## Developer Workflow
|
|
68
67
|
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
- Lint: `npm run lint`
|
|
72
|
-
- Format: `npm run format`
|
|
73
|
-
|
|
74
|
-
See `docs/quickstart.md` for details and `docs/architecture.md` for the protocol
|
|
75
|
-
and component overview.
|
|
68
|
+
- π¦ Make sure you have `beads-mcp` installed.
|
|
69
|
+
- π€ Ask your agent of choice. It will know.
|
|
76
70
|
|
|
77
71
|
## License
|
|
78
72
|
|
package/app/data/providers.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { MessageType } from '../protocol.js'
|
|
3
|
+
*/
|
|
1
4
|
/**
|
|
2
5
|
* Data layer: typed wrappers around the ws transport for bd-backed queries.
|
|
3
|
-
* @param {(type:
|
|
4
|
-
* @param {(type:
|
|
5
|
-
* @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => Promise<unknown[]>, getOpen: () => Promise<unknown[]>, getInProgress: () => Promise<unknown[]>, getClosed: (limit?: number) => Promise<unknown[]>, getIssue: (id: string) => Promise<unknown>, updateIssue: (input: { id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
6
|
+
* @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
|
|
7
|
+
* @param {(type: MessageType, handler: (payload: unknown) => void) => void} [onEvent] - Optional event subscription (used to invalidate caches on push updates).
|
|
8
|
+
* @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => Promise<unknown[]>, getBlocked: () => Promise<unknown[]>, getOpen: () => Promise<unknown[]>, getInProgress: () => Promise<unknown[]>, getClosed: (limit?: number) => Promise<unknown[]>, getIssue: (id: string) => Promise<unknown>, updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
6
9
|
*/
|
|
7
|
-
export function createDataLayer(transport,
|
|
8
|
-
/** @type {{ list_ready?: unknown, list_open?: unknown, list_in_progress?: unknown, list_closed_10?: unknown, epic_status?: unknown }} */
|
|
10
|
+
export function createDataLayer(transport, onEvent) {
|
|
11
|
+
/** @type {{ list_ready?: unknown, list_blocked?: unknown, list_open?: unknown, list_in_progress?: unknown, list_closed_10?: unknown, epic_status?: unknown }} */
|
|
9
12
|
const cache = {};
|
|
10
13
|
|
|
11
14
|
// Invalidate caches on server push updates when available
|
|
12
|
-
if (
|
|
15
|
+
if (onEvent) {
|
|
13
16
|
try {
|
|
14
|
-
|
|
17
|
+
onEvent('issues-changed', () => {
|
|
15
18
|
cache.list_ready = undefined;
|
|
19
|
+
cache.list_blocked = undefined;
|
|
16
20
|
cache.list_open = undefined;
|
|
17
21
|
cache.list_in_progress = undefined;
|
|
18
22
|
cache.list_closed_10 = undefined;
|
|
@@ -29,9 +33,8 @@ export function createDataLayer(transport, on_event) {
|
|
|
29
33
|
*/
|
|
30
34
|
async function getEpicStatus() {
|
|
31
35
|
if (Array.isArray(cache.epic_status)) {
|
|
32
|
-
return
|
|
36
|
+
return cache.epic_status;
|
|
33
37
|
}
|
|
34
|
-
/** @type {unknown} */
|
|
35
38
|
const res = await transport('epic-status');
|
|
36
39
|
const arr = Array.isArray(res) ? res : [];
|
|
37
40
|
cache.epic_status = arr;
|
|
@@ -45,7 +48,7 @@ export function createDataLayer(transport, on_event) {
|
|
|
45
48
|
*/
|
|
46
49
|
async function getReady() {
|
|
47
50
|
if (Array.isArray(cache.list_ready)) {
|
|
48
|
-
return
|
|
51
|
+
return cache.list_ready;
|
|
49
52
|
}
|
|
50
53
|
/** @type {unknown} */
|
|
51
54
|
const res = await transport('list-issues', { filters: { ready: true } });
|
|
@@ -54,15 +57,30 @@ export function createDataLayer(transport, on_event) {
|
|
|
54
57
|
return arr;
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Blocked issues: `bd blocked --json`.
|
|
62
|
+
* Sort by priority then updated_at on the UI; transport returns raw list.
|
|
63
|
+
* @returns {Promise<unknown[]>}
|
|
64
|
+
*/
|
|
65
|
+
async function getBlocked() {
|
|
66
|
+
if (Array.isArray(cache.list_blocked)) {
|
|
67
|
+
return cache.list_blocked;
|
|
68
|
+
}
|
|
69
|
+
/** @type {unknown} */
|
|
70
|
+
const res = await transport('list-issues', { filters: { blocked: true } });
|
|
71
|
+
const arr = Array.isArray(res) ? res : [];
|
|
72
|
+
cache.list_blocked = arr;
|
|
73
|
+
return arr;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/**
|
|
58
77
|
* Open issues: `bd list -s open --json`.
|
|
59
78
|
* @returns {Promise<unknown[]>}
|
|
60
79
|
*/
|
|
61
80
|
async function getOpen() {
|
|
62
81
|
if (Array.isArray(cache.list_open)) {
|
|
63
|
-
return
|
|
82
|
+
return cache.list_open;
|
|
64
83
|
}
|
|
65
|
-
/** @type {unknown} */
|
|
66
84
|
const res = await transport('list-issues', {
|
|
67
85
|
filters: { status: 'open' }
|
|
68
86
|
});
|
|
@@ -77,9 +95,8 @@ export function createDataLayer(transport, on_event) {
|
|
|
77
95
|
*/
|
|
78
96
|
async function getInProgress() {
|
|
79
97
|
if (Array.isArray(cache.list_in_progress)) {
|
|
80
|
-
return
|
|
98
|
+
return cache.list_in_progress;
|
|
81
99
|
}
|
|
82
|
-
/** @type {unknown} */
|
|
83
100
|
const res = await transport('list-issues', {
|
|
84
101
|
filters: { status: 'in_progress' }
|
|
85
102
|
});
|
|
@@ -89,22 +106,21 @@ export function createDataLayer(transport, on_event) {
|
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
/**
|
|
92
|
-
* Closed issues: `bd list
|
|
93
|
-
*
|
|
109
|
+
* Closed issues: `bd list --status closed --json`.
|
|
110
|
+
* Note: Do not send a `limit` for closed. The board applies a timeframe
|
|
111
|
+
* filter (today/3/7 days) client-side and needs the full closed set.
|
|
94
112
|
* @returns {Promise<unknown[]>}
|
|
95
113
|
*/
|
|
96
|
-
async function getClosed(
|
|
97
|
-
if (
|
|
98
|
-
|
|
114
|
+
async function getClosed() {
|
|
115
|
+
if (Array.isArray(cache.list_closed_10)) {
|
|
116
|
+
// Reuse existing cache slot for closed list to avoid widening cache API
|
|
117
|
+
return cache.list_closed_10;
|
|
99
118
|
}
|
|
100
|
-
/** @type {unknown} */
|
|
101
119
|
const res = await transport('list-issues', {
|
|
102
|
-
filters: { status: 'closed'
|
|
120
|
+
filters: { status: 'closed' }
|
|
103
121
|
});
|
|
104
122
|
const arr = Array.isArray(res) ? res : [];
|
|
105
|
-
|
|
106
|
-
cache.list_closed_10 = arr;
|
|
107
|
-
}
|
|
123
|
+
cache.list_closed_10 = arr;
|
|
108
124
|
return arr;
|
|
109
125
|
}
|
|
110
126
|
|
|
@@ -121,9 +137,9 @@ export function createDataLayer(transport, on_event) {
|
|
|
121
137
|
|
|
122
138
|
/**
|
|
123
139
|
* Update issue fields by dispatching specific mutations.
|
|
124
|
-
* Supported fields: title, acceptance, status, priority, assignee.
|
|
140
|
+
* Supported fields: title, acceptance, notes, design, status, priority, assignee.
|
|
125
141
|
* Returns the updated issue on success.
|
|
126
|
-
* @param {{ id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
142
|
+
* @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
127
143
|
* @returns {Promise<unknown>}
|
|
128
144
|
*/
|
|
129
145
|
async function updateIssue(input) {
|
|
@@ -144,6 +160,20 @@ export function createDataLayer(transport, on_event) {
|
|
|
144
160
|
value: input.acceptance
|
|
145
161
|
});
|
|
146
162
|
}
|
|
163
|
+
if (typeof input.notes === 'string') {
|
|
164
|
+
last = await transport('edit-text', {
|
|
165
|
+
id,
|
|
166
|
+
field: 'notes',
|
|
167
|
+
value: input.notes
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (typeof input.design === 'string') {
|
|
171
|
+
last = await transport('edit-text', {
|
|
172
|
+
id,
|
|
173
|
+
field: 'design',
|
|
174
|
+
value: input.design
|
|
175
|
+
});
|
|
176
|
+
}
|
|
147
177
|
if (typeof input.status === 'string') {
|
|
148
178
|
last = await transport('update-status', {
|
|
149
179
|
id,
|
|
@@ -169,6 +199,7 @@ export function createDataLayer(transport, on_event) {
|
|
|
169
199
|
return {
|
|
170
200
|
getEpicStatus,
|
|
171
201
|
getReady,
|
|
202
|
+
getBlocked,
|
|
172
203
|
getOpen,
|
|
173
204
|
getInProgress,
|
|
174
205
|
getClosed,
|
package/app/index.html
CHANGED
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
aria-label="Toggle dark mode"
|
|
22
22
|
/>
|
|
23
23
|
</label>
|
|
24
|
+
<button
|
|
25
|
+
id="new-issue-btn"
|
|
26
|
+
type="button"
|
|
27
|
+
aria-haspopup="dialog"
|
|
28
|
+
title="Create a new issue (Ctrl/Cmd+N)"
|
|
29
|
+
>
|
|
30
|
+
New issue
|
|
31
|
+
</button>
|
|
24
32
|
</div>
|
|
25
33
|
</header>
|
|
26
34
|
<main id="app" class="app-shell" aria-live="polite"></main>
|
package/app/main.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { html, render } from 'lit-html';
|
|
2
2
|
import { createDataLayer } from './data/providers.js';
|
|
3
|
-
import { createHashRouter } from './router.js';
|
|
3
|
+
import { createHashRouter, parseHash } from './router.js';
|
|
4
4
|
import { createStore } from './state.js';
|
|
5
|
+
import { showToast } from './utils/toast.js';
|
|
5
6
|
import { createBoardView } from './views/board.js';
|
|
6
7
|
import { createDetailView } from './views/detail.js';
|
|
7
8
|
import { createEpicsView } from './views/epics.js';
|
|
9
|
+
import { createIssueDialog } from './views/issue-dialog.js';
|
|
8
10
|
import { createListView } from './views/list.js';
|
|
9
11
|
import { createTopNav } from './views/nav.js';
|
|
12
|
+
import { createNewIssueDialog } from './views/new-issue-dialog.js';
|
|
10
13
|
import { createWsClient } from './ws.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -40,6 +43,22 @@ export function bootstrap(root_element) {
|
|
|
40
43
|
const detail_mount = document.getElementById('detail-panel');
|
|
41
44
|
if (list_mount && issues_root && epics_root && board_root && detail_mount) {
|
|
42
45
|
const client = createWsClient();
|
|
46
|
+
// Show toasts for WebSocket connectivity changes
|
|
47
|
+
/** @type {boolean} */
|
|
48
|
+
let had_disconnect = false;
|
|
49
|
+
if (typeof (/** @type {any} */ (client).onConnection) === 'function') {
|
|
50
|
+
/** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
|
|
51
|
+
const onConn = (s) => {
|
|
52
|
+
if (s === 'reconnecting' || s === 'closed') {
|
|
53
|
+
had_disconnect = true;
|
|
54
|
+
showToast('Connection lost. Reconnectingβ¦', 'error', 4000);
|
|
55
|
+
} else if (s === 'open' && had_disconnect) {
|
|
56
|
+
had_disconnect = false;
|
|
57
|
+
showToast('Reconnected', 'success', 2200);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** @type {any} */ (client).onConnection(onConn);
|
|
61
|
+
}
|
|
43
62
|
// Load persisted filters (status/search/type) from localStorage
|
|
44
63
|
/** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
|
|
45
64
|
let persistedFilters = { status: 'all', search: '', type: '' };
|
|
@@ -49,13 +68,11 @@ export function bootstrap(root_element) {
|
|
|
49
68
|
const obj = JSON.parse(raw);
|
|
50
69
|
if (obj && typeof obj === 'object') {
|
|
51
70
|
const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
52
|
-
/** @type {string} */
|
|
53
71
|
let parsed_type = '';
|
|
54
72
|
if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
|
|
55
73
|
parsed_type = obj.type;
|
|
56
74
|
} else if (Array.isArray(obj.types)) {
|
|
57
75
|
// Backwards compatibility: pick first valid from previous array format
|
|
58
|
-
/** @type {string} */
|
|
59
76
|
let first_valid = '';
|
|
60
77
|
for (const it of obj.types) {
|
|
61
78
|
if (ALLOWED.includes(String(it))) {
|
|
@@ -94,7 +111,29 @@ export function bootstrap(root_element) {
|
|
|
94
111
|
} catch {
|
|
95
112
|
// ignore
|
|
96
113
|
}
|
|
97
|
-
|
|
114
|
+
// Load board preferences
|
|
115
|
+
/** @type {{ closed_filter: 'today'|'3'|'7' }} */
|
|
116
|
+
let persistedBoard = { closed_filter: 'today' };
|
|
117
|
+
try {
|
|
118
|
+
const raw_board = window.localStorage.getItem('beads-ui.board');
|
|
119
|
+
if (raw_board) {
|
|
120
|
+
const obj = JSON.parse(raw_board);
|
|
121
|
+
if (obj && typeof obj === 'object') {
|
|
122
|
+
const cf = String(obj.closed_filter || 'today');
|
|
123
|
+
if (cf === 'today' || cf === '3' || cf === '7') {
|
|
124
|
+
persistedBoard.closed_filter = /** @type {any} */ (cf);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// ignore parse errors
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const store = createStore({
|
|
133
|
+
filters: persistedFilters,
|
|
134
|
+
view: last_view,
|
|
135
|
+
board: persistedBoard
|
|
136
|
+
});
|
|
98
137
|
const router = createHashRouter(store);
|
|
99
138
|
router.start();
|
|
100
139
|
/**
|
|
@@ -113,11 +152,30 @@ export function bootstrap(root_element) {
|
|
|
113
152
|
createTopNav(nav_mount, store, router);
|
|
114
153
|
}
|
|
115
154
|
|
|
155
|
+
// Global New Issue dialog (UI-106) mounted at root so it is always visible
|
|
156
|
+
const new_issue_dialog = createNewIssueDialog(
|
|
157
|
+
root_element,
|
|
158
|
+
(type, payload) => client.send(/** @type {any} */ (type), payload),
|
|
159
|
+
router,
|
|
160
|
+
store
|
|
161
|
+
);
|
|
162
|
+
// Header button
|
|
163
|
+
try {
|
|
164
|
+
const btn_new = /** @type {HTMLButtonElement|null} */ (
|
|
165
|
+
document.getElementById('new-issue-btn')
|
|
166
|
+
);
|
|
167
|
+
if (btn_new) {
|
|
168
|
+
btn_new.addEventListener('click', () => new_issue_dialog.open());
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore missing header
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
const issues_view = createListView(
|
|
117
175
|
list_mount,
|
|
118
176
|
transport,
|
|
119
177
|
(hash) => {
|
|
120
|
-
const id = hash
|
|
178
|
+
const id = parseHash(hash);
|
|
121
179
|
if (id) {
|
|
122
180
|
router.gotoIssue(id);
|
|
123
181
|
}
|
|
@@ -137,44 +195,103 @@ export function bootstrap(root_element) {
|
|
|
137
195
|
// ignore
|
|
138
196
|
}
|
|
139
197
|
});
|
|
198
|
+
// Persist board preferences
|
|
199
|
+
store.subscribe((s) => {
|
|
200
|
+
try {
|
|
201
|
+
window.localStorage.setItem(
|
|
202
|
+
'beads-ui.board',
|
|
203
|
+
JSON.stringify({ closed_filter: s.board.closed_filter })
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore
|
|
207
|
+
}
|
|
208
|
+
});
|
|
140
209
|
void issues_view.load();
|
|
141
|
-
|
|
142
|
-
|
|
210
|
+
|
|
211
|
+
// Dialog for issue details (UI-104)
|
|
212
|
+
const dialog = createIssueDialog(detail_mount, store, () => {
|
|
213
|
+
// Close: clear selection and return to current view
|
|
214
|
+
const s = store.getState();
|
|
215
|
+
store.setState({ selected_id: null });
|
|
216
|
+
try {
|
|
217
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
218
|
+
const v = s.view || 'issues';
|
|
219
|
+
router.gotoView(v);
|
|
220
|
+
} catch {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** @type {ReturnType<typeof createDetailView> | null} */
|
|
226
|
+
let detail = null;
|
|
227
|
+
// Mount details into the dialog body only
|
|
228
|
+
detail = createDetailView(dialog.getMount(), transport, (hash) => {
|
|
229
|
+
const id = parseHash(hash);
|
|
143
230
|
if (id) {
|
|
144
231
|
router.gotoIssue(id);
|
|
145
232
|
}
|
|
146
233
|
});
|
|
147
234
|
|
|
148
|
-
//
|
|
235
|
+
// If router already set a selected id (deep-link), open dialog now
|
|
236
|
+
const initial_id = store.getState().selected_id;
|
|
237
|
+
if (initial_id) {
|
|
238
|
+
detail_mount.hidden = false;
|
|
239
|
+
dialog.open(initial_id);
|
|
240
|
+
if (detail) {
|
|
241
|
+
void detail.load(initial_id);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Open/close dialog based on selected_id (always dialog; no page variant)
|
|
149
246
|
store.subscribe((s) => {
|
|
150
247
|
const id = s.selected_id;
|
|
151
248
|
if (id) {
|
|
152
|
-
|
|
249
|
+
detail_mount.hidden = false;
|
|
250
|
+
dialog.open(id);
|
|
251
|
+
if (detail) {
|
|
252
|
+
void detail.load(id);
|
|
253
|
+
}
|
|
153
254
|
} else {
|
|
154
|
-
|
|
255
|
+
try {
|
|
256
|
+
dialog.close();
|
|
257
|
+
} catch {
|
|
258
|
+
// ignore
|
|
259
|
+
}
|
|
260
|
+
if (detail) {
|
|
261
|
+
detail.clear();
|
|
262
|
+
}
|
|
263
|
+
detail_mount.hidden = true;
|
|
155
264
|
}
|
|
156
265
|
});
|
|
157
266
|
|
|
158
|
-
// Initial deep-link: if router set a selectedId before subscription, load it now
|
|
159
|
-
const initialId = store.getState().selected_id;
|
|
160
|
-
if (initialId) {
|
|
161
|
-
void detail.load(initialId);
|
|
162
|
-
} else {
|
|
163
|
-
detail.clear();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
267
|
// Refresh views on push updates (target minimally and avoid flicker)
|
|
268
|
+
// UI-114: Coalesce near-simultaneous events. When an ID-scoped update
|
|
269
|
+
// arrives, suppress a trailing watcher-only full refresh for a short
|
|
270
|
+
// window to avoid duplicate work and flicker.
|
|
271
|
+
/** @type {number} */
|
|
272
|
+
let suppress_full_until = 0;
|
|
167
273
|
client.on('issues-changed', (payload) => {
|
|
168
274
|
const s = store.getState();
|
|
169
|
-
const
|
|
275
|
+
const hint_ids =
|
|
170
276
|
payload && payload.hint && Array.isArray(payload.hint.ids)
|
|
171
277
|
? /** @type {string[]} */ (payload.hint.ids)
|
|
172
278
|
: null;
|
|
173
279
|
|
|
174
|
-
const
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
if (!hint_ids || hint_ids.length === 0) {
|
|
282
|
+
if (now <= suppress_full_until) {
|
|
283
|
+
// Drop redundant full refresh that follows a targeted update.
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
// Prefer ID-scoped updates for a brief window.
|
|
288
|
+
suppress_full_until = now + 500;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const showing_detail = Boolean(s.selected_id);
|
|
175
292
|
|
|
176
293
|
// If a top-level view is visible (and not detail), refresh that view
|
|
177
|
-
if (!
|
|
294
|
+
if (!showing_detail) {
|
|
178
295
|
if (s.view === 'issues') {
|
|
179
296
|
void issues_view.load();
|
|
180
297
|
} else if (s.view === 'epics') {
|
|
@@ -185,9 +302,11 @@ export function bootstrap(root_element) {
|
|
|
185
302
|
}
|
|
186
303
|
|
|
187
304
|
// If a detail is visible, re-fetch it when relevant or when hints are absent
|
|
188
|
-
if (
|
|
189
|
-
if (!
|
|
190
|
-
|
|
305
|
+
if (showing_detail && s.selected_id) {
|
|
306
|
+
if (!hint_ids || hint_ids.includes(s.selected_id)) {
|
|
307
|
+
if (detail) {
|
|
308
|
+
void detail.load(s.selected_id);
|
|
309
|
+
}
|
|
191
310
|
}
|
|
192
311
|
}
|
|
193
312
|
});
|
|
@@ -197,25 +316,28 @@ export function bootstrap(root_element) {
|
|
|
197
316
|
const epics_view = createEpicsView(epics_root, data, (id) =>
|
|
198
317
|
router.gotoIssue(id)
|
|
199
318
|
);
|
|
200
|
-
const board_view = createBoardView(
|
|
201
|
-
|
|
319
|
+
const board_view = createBoardView(
|
|
320
|
+
board_root,
|
|
321
|
+
data,
|
|
322
|
+
(id) => router.gotoIssue(id),
|
|
323
|
+
store
|
|
202
324
|
);
|
|
203
325
|
// Preload epics when switching to view
|
|
204
326
|
/**
|
|
205
327
|
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
206
328
|
*/
|
|
207
329
|
const onRouteChange = (s) => {
|
|
208
|
-
const showDetail = Boolean(s.selected_id);
|
|
209
330
|
if (issues_root && epics_root && board_root && detail_mount) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
331
|
+
// Underlying route visibility is controlled only by selected view
|
|
332
|
+
issues_root.hidden = s.view !== 'issues';
|
|
333
|
+
epics_root.hidden = s.view !== 'epics';
|
|
334
|
+
board_root.hidden = s.view !== 'board';
|
|
335
|
+
// detail_mount visibility handled in subscription above
|
|
214
336
|
}
|
|
215
|
-
if (!
|
|
337
|
+
if (!s.selected_id && s.view === 'epics') {
|
|
216
338
|
void epics_view.load();
|
|
217
339
|
}
|
|
218
|
-
if (!
|
|
340
|
+
if (!s.selected_id && s.view === 'board') {
|
|
219
341
|
void board_view.load();
|
|
220
342
|
}
|
|
221
343
|
try {
|
|
@@ -227,6 +349,30 @@ export function bootstrap(root_element) {
|
|
|
227
349
|
store.subscribe(onRouteChange);
|
|
228
350
|
// Ensure initial state is reflected (fixes reload on #/epics)
|
|
229
351
|
onRouteChange(store.getState());
|
|
352
|
+
|
|
353
|
+
// Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
|
|
354
|
+
window.addEventListener('keydown', (ev) => {
|
|
355
|
+
const is_modifier = ev.ctrlKey || ev.metaKey;
|
|
356
|
+
const key = String(ev.key || '').toLowerCase();
|
|
357
|
+
/** @type {HTMLElement} */
|
|
358
|
+
const target = /** @type {any} */ (ev.target);
|
|
359
|
+
const tag =
|
|
360
|
+
target && target.tagName ? String(target.tagName).toLowerCase() : '';
|
|
361
|
+
const is_editable =
|
|
362
|
+
tag === 'input' ||
|
|
363
|
+
tag === 'textarea' ||
|
|
364
|
+
tag === 'select' ||
|
|
365
|
+
(target &&
|
|
366
|
+
typeof target.isContentEditable === 'boolean' &&
|
|
367
|
+
target.isContentEditable);
|
|
368
|
+
if (is_modifier && key === 'n') {
|
|
369
|
+
// Do not hijack when typing in inputs; common UX
|
|
370
|
+
if (!is_editable) {
|
|
371
|
+
ev.preventDefault();
|
|
372
|
+
new_issue_dialog.open();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
230
376
|
}
|
|
231
377
|
}
|
|
232
378
|
|
package/app/protocol.md
CHANGED
|
@@ -23,8 +23,7 @@ ReplyEnvelope shape with `ok: true` and a generated `id`.
|
|
|
23
23
|
- `update-status` payload:
|
|
24
24
|
`{ id: string, status: 'open'|'in_progress'|'closed' }`
|
|
25
25
|
- `edit-text` payload:
|
|
26
|
-
`{ id: string, field: 'title'|'description'|'acceptance', value: string }`
|
|
27
|
-
- Note: `description` edits are rejected by the server (unsupported by `bd`).
|
|
26
|
+
`{ id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }`
|
|
28
27
|
- `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
|
|
29
28
|
- `create-issue` payload:
|
|
30
29
|
`{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
|
|
@@ -44,8 +43,8 @@ ReplyEnvelope shape with `ok: true` and a generated `id`.
|
|
|
44
43
|
- `list-issues` β `bd list --json [--status <s>] [--priority <n>]`
|
|
45
44
|
- `show-issue` β `bd show <id> --json`
|
|
46
45
|
- `update-status` β `bd update <id> --status <status>`
|
|
47
|
-
- `edit-text` β `bd update <id> --title <t>` or `--
|
|
48
|
-
-
|
|
46
|
+
- `edit-text` β `bd update <id> --title <t>` or `--description <d>` or
|
|
47
|
+
`--acceptance-criteria <a>` or `--notes <n>` or `--design <z>`
|
|
49
48
|
- `update-priority` β `bd update <id> --priority <n>`
|
|
50
49
|
- `create-issue` β `bd create "title" -t <type> -p <prio> -d "desc"`
|
|
51
50
|
- `list-ready` β `bd ready --json`
|