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 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 (Maximilian Antoni)
27
+ - πŸ“¦ Specify files to package
6
28
 
7
29
  ## 0.1.1
8
30
 
9
- - πŸ“š Make screenshot src absolute and add license (Maximilian Antoni)
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
- # beads-ui
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
- Local‑first UI for the `bd` CLI (beads) β€” a fast, dependency‑aware issue
4
- tracker.
16
+ ## Features
5
17
 
6
- beads-ui complements the upstream beads project by providing a single‑page web
7
- app served from a local Node.js server. It talks to `bd` over a local WebSocket
8
- to list issues, show details, and apply edits. All changes happen by executing
9
- `bd` commands, and live updates flow in as the database changes on disk.
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
- Upstream beads (CLI and docs): https://github.com/steveyegge/beads
26
+ ## Setup
12
27
 
13
- ## Features
28
+ ```sh
29
+ npm i -g beads-ui
30
+ bdui start --open
31
+ ```
14
32
 
15
- - Issues list with inline edits, quick filters, and keyboard navigation
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
  ![Issues view](https://github.com/mantoni/beads-ui/raw/main/media/bdui-issues.png)
28
40
 
29
- Epics
41
+ **Epics**
30
42
 
31
43
  ![Epics view](https://github.com/mantoni/beads-ui/raw/main/media/bdui-epics.png)
32
44
 
33
- Board
45
+ **Board**
34
46
 
35
47
  ![Board view](https://github.com/mantoni/beads-ui/raw/main/media/bdui-board.png)
36
48
 
37
- ## Quickstart
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
- - Type check: `npm run typecheck`
70
- - Tests: `npm test`
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
 
@@ -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: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
4
- * @param {(type: import('../protocol.js').MessageType, handler: (payload: unknown) => void) => void} [on_event] - Optional event subscription (used to invalidate caches on push updates).
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, on_event) {
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 (on_event) {
15
+ if (onEvent) {
13
16
  try {
14
- on_event('issues-changed', () => {
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 /** @type {unknown[]} */ (cache.epic_status);
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 /** @type {unknown[]} */ (cache.list_ready);
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 /** @type {unknown[]} */ (cache.list_open);
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 /** @type {unknown[]} */ (cache.list_in_progress);
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 -s closed -l 10 --json`.
93
- * @param {number} [limit] - Optional limit (defaults to 10).
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(limit = 10) {
97
- if (limit === 10 && Array.isArray(cache.list_closed_10)) {
98
- return /** @type {unknown[]} */ (cache.list_closed_10);
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', limit }
120
+ filters: { status: 'closed' }
103
121
  });
104
122
  const arr = Array.isArray(res) ? res : [];
105
- if (limit === 10) {
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
- const store = createStore({ filters: persistedFilters, view: last_view });
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.replace('#/issue/', '');
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
- const detail = createDetailView(detail_mount, transport, (hash) => {
142
- const id = hash.replace('#/issue/', '');
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
- // React to selectedId changes -> show detail page full-width
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
- void detail.load(id);
249
+ detail_mount.hidden = false;
250
+ dialog.open(id);
251
+ if (detail) {
252
+ void detail.load(id);
253
+ }
153
254
  } else {
154
- detail.clear();
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 hintIds =
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 showingDetail = Boolean(s.selected_id);
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 (!showingDetail) {
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 (showingDetail && s.selected_id) {
189
- if (!hintIds || hintIds.includes(s.selected_id)) {
190
- void detail.load(s.selected_id);
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(board_root, data, (id) =>
201
- router.gotoIssue(id)
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
- issues_root.hidden = showDetail || s.view !== 'issues';
211
- epics_root.hidden = showDetail || s.view !== 'epics';
212
- board_root.hidden = showDetail || s.view !== 'board';
213
- detail_mount.hidden = !showDetail;
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 (!showDetail && s.view === 'epics') {
337
+ if (!s.selected_id && s.view === 'epics') {
216
338
  void epics_view.load();
217
339
  }
218
- if (!showDetail && s.view === 'board') {
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 `--acceptance-criteria <a>`
48
- - `description` has no CLI flag; server responds with an error
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`