claude-code-kanban 7.24.0 → 7.26.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/hub-app.json CHANGED
@@ -4,12 +4,15 @@
4
4
  "name": "Kanban",
5
5
  "icon": "columns",
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
- "publishes": ["session.changed"],
7
+ "publishes": ["session.changed", "voice.context"],
8
8
  "actions": {
9
9
  "handles": {
10
10
  "session.open": { "params": { "session": "string" }, "mode": "message" },
11
11
  "session.find": { "params": { "name": "string?" }, "mode": "message" },
12
12
  "session.paste": { "params": { "text": "string", "submit": "string?", "session": "string?" }, "mode": "message" },
13
+ "session.approve": { "params": {}, "mode": "message" },
14
+ "session.deny": { "params": { "message": "string?" }, "mode": "message" },
15
+ "session.next": { "params": {}, "mode": "message" },
13
16
  "task.add": { "params": { "subject": "string?", "description": "string?" }, "mode": "message" },
14
17
  "task.live": { "params": { "subject": "string?", "description": "string?", "pending": "string?", "final": "string?", "submit": "string?", "cancel": "string?" }, "mode": "message" },
15
18
  "session.live": { "params": { "text": "string", "final": "string?", "submit": "string?", "cancel": "string?", "session": "string?" }, "mode": "message" },
@@ -52,7 +55,7 @@
52
55
  "default": "dots"
53
56
  }
54
57
  ],
55
- "keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
58
+ "keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+alt+shift+s","ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
56
59
  "plugin": {
57
60
  "marketplace": "plugin",
58
61
  "name": "claude-code-kanban",
@@ -0,0 +1,38 @@
1
+ {
2
+ "surface": 1,
3
+ "functions": {
4
+ "renderTaskCard": "(task) -> the HTML string of one board card. Return the same string for the same task: the board rewrites a column only when its HTML changes.",
5
+ "renderSessions": "() -> rebuilds the session list in the sidebar. It returns early while the user drags or types in the list.",
6
+ "renderKanban": "() -> rebuilds the three board columns from currentTasks.",
7
+ "renderActivityChip": "() -> {waiting, active, terminal}: counts the sessions in each state and redraws the chips above the session list when the counts change. It runs on every session poll."
8
+ },
9
+ "events": {
10
+ "sessions.rendered": "After each renderSessions call, also one that returned early.",
11
+ "board.rendered": "After each renderKanban call.",
12
+ "activity.rendered": "After each renderActivityChip call, with its {waiting, active, terminal} counts as the detail. A waiting session also counts as active."
13
+ },
14
+ "parts": {
15
+ "sidebar": "The sidebar: header, session list, footer.",
16
+ "sessions": "The session list.",
17
+ "board": "The board with its three columns.",
18
+ "session-card": "One session in the session list, with data-session-id.",
19
+ "sidebar-footer": "The footer under the session list. Its children are rebuilt on each poll; the element stays.",
20
+ "progress": "The track of the progress bar in the session header.",
21
+ "progress-fill": "The fill inside progress, whose width is the share of the session's tasks done.",
22
+ "kanbot": "The Kanbot mascot, a 16x13 svg whose head is x 3-13 at the top row. It sits in the sidebar footer's button and in the Kanbot panel's head.",
23
+ "idle-mascot": "The mascot over the empty state, an svg.clawd that floats up and down.",
24
+ "task-card": "One card on the board, with data-task-id and data-session-id.",
25
+ "task-number": "The #id label at the top left of a task card.",
26
+ "done-title": "The title of the Completed column."
27
+ },
28
+ "states": {
29
+ "session-card": {
30
+ "active": "While it is the open session."
31
+ },
32
+ "task-card": {
33
+ "pending": "Not started.",
34
+ "in-progress": "Being worked on.",
35
+ "completed": "Done."
36
+ }
37
+ }
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.24.0",
3
+ "version": "7.26.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -55,6 +55,7 @@
55
55
  "files": [
56
56
  "server.js",
57
57
  "hub-app.json",
58
+ "mod-surface.json",
58
59
  "cli.js",
59
60
  "install.js",
60
61
  "plugin/**/*",
package/public/app.js CHANGED
@@ -95,8 +95,10 @@ let sessionsHasMore = false;
95
95
  // URL, so a query string would not survive a hub reload.
96
96
  const ZEN_KEY = 'zenMode';
97
97
  let zenMode = store.getItem(ZEN_KEY) === 'true';
98
- // Group zen: the sidebar shows one group's subtree. A view of the moment, so it is not persisted.
98
+ // Group and project zen: the sidebar shows one group's subtree or one project. A view of the
99
+ // moment, so neither is persisted.
99
100
  let zenGroup = null;
101
+ let zenProject = null;
100
102
  let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
101
103
  let recentProjects = new Set();
102
104
  let projectsCacheDirty = true;
@@ -236,7 +238,7 @@ function updateUrl() {
236
238
  history.replaceState(null, '', url);
237
239
  persistLastView();
238
240
  syncTerminal();
239
- schedulePublishSession();
241
+ schedulePublish();
240
242
  }
241
243
 
242
244
  const LAST_VIEW_KEY = 'lastView';
@@ -263,6 +265,8 @@ function resetState() {
263
265
  filterProject = FILTER_DEFAULTS.project;
264
266
  zenMode = false;
265
267
  zenGroup = null;
268
+ zenProject = null;
269
+ setZenPeek(null);
266
270
  store.removeItem(ZEN_KEY);
267
271
  renderZenState();
268
272
  searchQuery = '';
@@ -335,8 +339,10 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
335
339
  // info modal both look it up in `sessions` and bail when it is absent, so a session
336
340
  // opened from outside the current filter would leave the view on the previous one.
337
341
  // The server adds the pinned sessions itself; the ones `session open` showed are this page's.
338
- // Group zen ignores the filters, and its session members come back through `include`.
339
- const zenIds = zenGroup ? sgSubtreeIds(zenGroup) : new Set();
342
+ // Group and project zen keep the Active/All filter and drop the others. Under All, a group's
343
+ // session members come back through `include`, as they may be past the page limit.
344
+ const zenScope = !!(zenGroup || zenProject);
345
+ const zenIds = zenGroup && sessionFilter !== 'active' ? sgSubtreeIds(zenGroup) : new Set();
340
346
  const zenMembers = sessionGroups
341
347
  .filter((g) => zenIds.has(g.id))
342
348
  .flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
@@ -347,19 +353,24 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
347
353
  ...openedStickyIds,
348
354
  ...zenMembers,
349
355
  ].filter(Boolean);
350
- const recent = !zenGroup && filterProject === '__recent__';
356
+ const recent = !zenScope && filterProject === '__recent__';
357
+ const project = zenProject || (!zenScope && !recent && filterProject) || null;
358
+ const listScope = `${project}|${recent}|${sessionFilter}|${sessionLimit}`;
351
359
  const res = await api('/api/sessions', {
352
360
  query: {
353
361
  limit: sessionLimit,
354
362
  include: include.length ? include.join(',') : null,
355
363
  recentHours: recent ? RECENT_PROJECT_HOURS : null,
356
- project: !zenGroup && !recent && filterProject ? filterProject : null,
357
- filter: !zenGroup && sessionFilter === 'active' ? 'active' : null,
364
+ project,
365
+ filter: sessionFilter === 'active' ? 'active' : null,
358
366
  },
359
367
  });
360
368
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
361
369
  sessionsHasMore = res.headers.get('X-Has-More') === 'true';
362
370
  const newSessions = await res.json();
371
+ // A list from another server scope would make every session it adds look newly active.
372
+ if (listScope !== lastListScope) prevActiveSessionIds = null;
373
+ lastListScope = listScope;
363
374
  const sessionsHash = JSON.stringify(newSessions);
364
375
  if (sessionsHash !== lastSessionsHash) {
365
376
  lastSessionsHash = sessionsHash;
@@ -368,7 +379,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
368
379
  sgSweepTransient();
369
380
  renderSessions();
370
381
  renderActivityChip();
371
- schedulePublishSession();
382
+ schedulePublish();
372
383
  }
373
384
  // A list too short to scroll gives no scroll event, so fill it until it can scroll.
374
385
  if (sessionsHasMore) setTimeout(() => loadMoreIfNearEnd());
@@ -490,7 +501,8 @@ function sessionSearch(onFound) {
490
501
  };
491
502
  }
492
503
 
493
- const sidebarSearch = sessionSearch(renderSessions);
504
+ // A lambda, not the function: a hub mod replaces window.renderSessions after this line runs.
505
+ const sidebarSearch = sessionSearch(() => renderSessions());
494
506
 
495
507
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
496
508
  function handleSearch(query) {
@@ -681,9 +693,11 @@ function renderActivityChip() {
681
693
  `;
682
694
  })
683
695
  .join('');
684
- if (html === lastChipHtml) return;
685
- lastChipHtml = html;
686
- container.innerHTML = html;
696
+ if (html !== lastChipHtml) {
697
+ lastChipHtml = html;
698
+ container.innerHTML = html;
699
+ }
700
+ return counts;
687
701
  }
688
702
 
689
703
  function toggleActivityKind(kind) {
@@ -1799,8 +1813,12 @@ function planApprovalControlsHtml(inputId) {
1799
1813
 
1800
1814
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
1801
1815
  function rejectWaitingPlan(inputId) {
1802
- const feedback = document.getElementById(inputId)?.value?.trim();
1803
- respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
1816
+ denyWaiting(document.getElementById(inputId)?.value);
1817
+ }
1818
+
1819
+ function denyWaiting(feedback) {
1820
+ const message = feedback?.trim() || (currentWaiting?.kind === 'plan' ? PLAN_REJECT_MSG : '');
1821
+ respondWaiting({ behavior: 'deny', ...(message && { message }) });
1804
1822
  }
1805
1823
 
1806
1824
  // UI-driven approvals: POST the decision the plugin's mod is polling for.
@@ -3530,6 +3548,7 @@ function visibleAgents() {
3530
3548
  }
3531
3549
 
3532
3550
  function renderAgentFooter() {
3551
+ schedulePublish();
3533
3552
  const footer = document.getElementById('agent-footer');
3534
3553
  const content = document.getElementById('agent-footer-content');
3535
3554
  const label = document.getElementById('agent-footer-label');
@@ -3821,7 +3840,10 @@ function showNoSession() {
3821
3840
  // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
3822
3841
  // project filter → search filter → ensure pinned/sticky sessions are always included.
3823
3842
  // Pure over module state, so the session picker can ask for the same list without a render.
3824
- function getFilteredSessions() {
3843
+ // Zen on a group or project drops the project filter and the search: its scope replaces both.
3844
+ function getFilteredSessions({ zen = false } = {}) {
3845
+ const project = zen ? null : filterProject;
3846
+ const search = zen ? '' : searchQuery;
3825
3847
  let filteredSessions = sessions;
3826
3848
  if (sessionFilter === 'active') {
3827
3849
  const activeSessionIds = new Set();
@@ -3851,7 +3873,7 @@ function getFilteredSessions() {
3851
3873
  }
3852
3874
  }
3853
3875
  }
3854
- if (filterProject) {
3876
+ if (project) {
3855
3877
  // The open session keeps its row even when it belongs to another project, so that
3856
3878
  // revealing one by id has something to scroll to. Same exemption the active branch
3857
3879
  // above already makes, for the same reason. recentProjects lags behind the session list, so a
@@ -3861,7 +3883,7 @@ function getFilteredSessions() {
3861
3883
  (s) =>
3862
3884
  matchesProjectFilter(s.project, s.worktree?.repo) ||
3863
3885
  s.id === currentSessionId ||
3864
- (filterProject === '__recent__' && (isAnyPinned(s.id) || Date.parse(s.modifiedAt) >= recentCutoff)),
3886
+ (project === '__recent__' && (isAnyPinned(s.id) || Date.parse(s.modifiedAt) >= recentCutoff)),
3865
3887
  );
3866
3888
  }
3867
3889
 
@@ -3872,22 +3894,22 @@ function getFilteredSessions() {
3872
3894
  }
3873
3895
  }
3874
3896
 
3875
- if (searchQuery) {
3897
+ if (search) {
3876
3898
  const taskMatchIds = new Set();
3877
3899
  for (const t of allTasksCache) {
3878
3900
  if (
3879
- (t.subject && fuzzyMatch(t.subject, searchQuery)) ||
3880
- (t.description && fuzzyMatch(t.description, searchQuery)) ||
3881
- (t.activeForm && fuzzyMatch(t.activeForm, searchQuery))
3901
+ (t.subject && fuzzyMatch(t.subject, search)) ||
3902
+ (t.description && fuzzyMatch(t.description, search)) ||
3903
+ (t.activeForm && fuzzyMatch(t.activeForm, search))
3882
3904
  )
3883
3905
  taskMatchIds.add(t.sessionId);
3884
3906
  }
3885
- const groupMatchIds = sgSearchMatchIds(searchQuery);
3907
+ const groupMatchIds = sgSearchMatchIds(search);
3886
3908
  const matchesSearch = (s) =>
3887
- (s.name && fuzzyMatch(s.name, searchQuery)) ||
3888
- (s.id && fuzzyMatch(s.id, searchQuery)) ||
3889
- (s.project && fuzzyMatch(s.project, searchQuery)) ||
3890
- (s.description && fuzzyMatch(s.description, searchQuery)) ||
3909
+ (s.name && fuzzyMatch(s.name, search)) ||
3910
+ (s.id && fuzzyMatch(s.id, search)) ||
3911
+ (s.project && fuzzyMatch(s.project, search)) ||
3912
+ (s.description && fuzzyMatch(s.description, search)) ||
3891
3913
  taskMatchIds.has(s.id) ||
3892
3914
  groupMatchIds.has(s.id);
3893
3915
 
@@ -3910,7 +3932,7 @@ function getFilteredSessions() {
3910
3932
  if (
3911
3933
  sessionFilter === 'active' &&
3912
3934
  activityFilter.size === 0 &&
3913
- !searchQuery &&
3935
+ !search &&
3914
3936
  (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)
3915
3937
  ) {
3916
3938
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
@@ -3961,6 +3983,7 @@ function zenPanelIsEditing() {
3961
3983
  function renderSessions() {
3962
3984
  // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3963
3985
  // half-typed group name, and the SSE path can fire at any moment — defer instead.
3986
+ schedulePublish();
3964
3987
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3965
3988
  scheduleJustFinishedExpiry();
3966
3989
  refreshProjectList();
@@ -3968,20 +3991,25 @@ function renderSessions() {
3968
3991
 
3969
3992
  if (zenGroup && !sgGroupById(zenGroup)) {
3970
3993
  zenGroup = null;
3994
+ zenPeekKey = null;
3971
3995
  renderZenState();
3972
3996
  }
3973
3997
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3974
3998
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
3975
- // Group zen renders only its subtree's buckets, so it takes every session unfiltered.
3976
3999
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3977
4000
  sessionsList.classList.toggle('zen', !!zenSession);
3978
4001
  sessionsList.dataset.zenState = zenSession ? sessionState(zenSession) : '';
4002
+ const zenScope = !!(zenGroup || zenProject);
3979
4003
  let filteredSessions;
3980
- if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
3981
- else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
4004
+ if (zenScope) {
4005
+ const scoped = getFilteredSessions({ zen: true });
4006
+ filteredSessions = applyStableSessionOrder(
4007
+ zenProject ? scoped.filter((s) => sessionProjectKey(s) === zenProject) : scoped,
4008
+ );
4009
+ } else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
3982
4010
  else filteredSessions = applyStableSessionOrder(getFilteredSessions());
3983
4011
 
3984
- const outside = sidebarSearch.rows(zenMode || zenGroup ? '' : searchQuery, filteredSessions);
4012
+ const outside = sidebarSearch.rows(zenMode || zenScope ? '' : searchQuery, filteredSessions);
3985
4013
 
3986
4014
  const childDepth = new Map();
3987
4015
  const parentOf = new Map();
@@ -4056,7 +4084,7 @@ function renderSessions() {
4056
4084
  : '';
4057
4085
  const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
4058
4086
  return `
4059
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}${childDepthClass(childDepth.get(session.id))}">
4087
+ <button onclick="openSession('${sid}')" draggable="true" data-hub-part="session-card" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}${childDepthClass(childDepth.get(session.id))}">
4060
4088
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
4061
4089
  <div class="session-name">${escapeHtml(sessionName)}</div>
4062
4090
  ${renderSessionIdentity(projectHtml, session.gitBranch)}
@@ -4110,7 +4138,7 @@ function renderSessions() {
4110
4138
  )
4111
4139
  : '';
4112
4140
 
4113
- if (filteredSessions.length === 0 && !zenGroup) {
4141
+ if (filteredSessions.length === 0 && !zenScope) {
4114
4142
  let emptyMsg = 'No sessions found';
4115
4143
  let emptyHint = 'Tasks appear when you use Claude Code';
4116
4144
 
@@ -4219,7 +4247,7 @@ function renderSessions() {
4219
4247
  .join('');
4220
4248
 
4221
4249
  return `
4222
- <div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
4250
+ <div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}${projectPath === zenProject ? ' sg-zen' : ''}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
4223
4251
  ${groupChevronSvg()}
4224
4252
  <span class="group-name">${escapeHtml(folderName)}</span>
4225
4253
  ${terminalAvailable() ? `<span class="project-new-btn" data-project-path="${escapedPath}" title="New session in ${escapeHtml(folderName)}">${PLUS_SVG}</span>` : ''}
@@ -4251,7 +4279,7 @@ function renderSessions() {
4251
4279
  }
4252
4280
  // A group emptied by a filter hides its header; a group the user just created (no filters)
4253
4281
  // shows its drop-here state instead of vanishing.
4254
- const sgFiltering = !zenGroup && sgListFiltered();
4282
+ const sgFiltering = zenGroup ? activityFilter.size > 0 : sgListFiltered();
4255
4283
 
4256
4284
  // A group whose parent is gone renders at the top.
4257
4285
  const sgIds = new Set(sessionGroups.map((g) => g.id));
@@ -4340,7 +4368,16 @@ function renderSessions() {
4340
4368
  }
4341
4369
 
4342
4370
  if (zenGroup) {
4343
- sessionsList.innerHTML = sgGroupHtml(sgGroupById(zenGroup), sessionFilter !== 'active');
4371
+ const group = sgGroupById(zenGroup);
4372
+ sessionsList.innerHTML =
4373
+ sgGroupHtml(group, sessionFilter !== 'active') ||
4374
+ `${sgHeaderHtml(group, countHtml([]))}<div class="sg-empty">No sessions match the filters</div>`;
4375
+ } else if (zenProject) {
4376
+ sessionsList.innerHTML =
4377
+ projectBlock(zenProject, filteredSessions, false) +
4378
+ (filteredSessions.length || sessionFilter !== 'active'
4379
+ ? ''
4380
+ : '<div class="sg-empty">No active sessions. Select "All Sessions" to see all</div>');
4344
4381
  } else if (sessionFilter === 'active') {
4345
4382
  // Group active sessions by project
4346
4383
  // Auto-expand a collapsed section when newly-active work lands in it (live refresh).
@@ -4574,6 +4611,7 @@ function renderTaskCard(task) {
4574
4611
  <div
4575
4612
  role="listitem"
4576
4613
  tabindex="0"
4614
+ data-hub-part="task-card"
4577
4615
  data-task-id="${escapeHtml(task.id)}"
4578
4616
  data-session-id="${escapeHtml(actualSessionId)}"
4579
4617
  onclick="showTaskDetail('${escAttrJs(task.id)}', '${escAttrJs(actualSessionId)}')"
@@ -4583,7 +4621,7 @@ function renderTaskCard(task) {
4583
4621
  class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''} ${isDim ? 'dim' : ''}"
4584
4622
  aria-label="${escapeHtml(task.subject)} — ${escapeHtml(state)}">
4585
4623
  <div class="task-id">
4586
- <span>#${taskId}</span>
4624
+ <span data-hub-part="task-number">#${taskId}</span>
4587
4625
  ${renderTaskChips(chips)}
4588
4626
  ${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
4589
4627
  ${task.owner ? renderOwnerBadge(task) : ''}
@@ -4598,50 +4636,6 @@ function renderTaskCard(task) {
4598
4636
  //#endregion
4599
4637
 
4600
4638
  //#region KANBAN
4601
- // FLIP pass around renderKanban's innerHTML rebuild (#41): the rebuild lands cards at
4602
- // their final spot instantly, so a status change reads as a full-board reload. Recording
4603
- // where each card was and animating from there makes a move glide instead.
4604
- const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
4605
-
4606
- function cardFlipKey(el) {
4607
- return `${el.dataset.sessionId}|${el.dataset.taskId}`;
4608
- }
4609
-
4610
- function captureCardRects() {
4611
- const rects = new Map();
4612
- for (const el of document.querySelectorAll('.column-tasks .task-card')) {
4613
- rects.set(cardFlipKey(el), el.getBoundingClientRect());
4614
- }
4615
- return rects;
4616
- }
4617
-
4618
- function playCardFlip(before) {
4619
- // An empty board before the render means a view switch, not a move — nothing to glide.
4620
- if (reducedMotion.matches || before.size === 0) return;
4621
- for (const el of document.querySelectorAll('.column-tasks .task-card')) {
4622
- const prev = before.get(cardFlipKey(el));
4623
- if (!prev) {
4624
- el.animate(
4625
- [
4626
- { opacity: 0, transform: 'scale(0.97)' },
4627
- { opacity: 1, transform: 'none' },
4628
- ],
4629
- { duration: 150, easing: 'ease-out' },
4630
- );
4631
- continue;
4632
- }
4633
- const now = el.getBoundingClientRect();
4634
- const dx = prev.left - now.left;
4635
- const dy = prev.top - now.top;
4636
- if (dx || dy) {
4637
- el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
4638
- duration: 220,
4639
- easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)',
4640
- });
4641
- }
4642
- }
4643
- }
4644
-
4645
4639
  // Rewrite a column only when its markup actually changed: SSE ticks fire on any task-file
4646
4640
  // write (updatedAt bumps, agent refreshes), and an unconditional innerHTML rebuild makes
4647
4641
  // the whole board blink for updates with nothing visible in them (#41).
@@ -4667,41 +4661,33 @@ function renderKanban() {
4667
4661
  const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
4668
4662
  const plusIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg>`;
4669
4663
 
4670
- const writes = [];
4671
4664
  // Adding is a live text input inside the column, so a background refresh would blow it
4672
4665
  // away mid-typing -- leave the column alone until the input is gone.
4673
4666
  if (!addingTask) {
4674
4667
  const addTile = canAddTask()
4675
4668
  ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this, event)" title="Ctrl+Click opens the dialog with a description. You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
4676
4669
  : '';
4677
- writes.push([
4670
+ setColumnHtml(
4678
4671
  pendingTasks,
4679
4672
  pending.length > 0
4680
4673
  ? pending.map(renderTaskCard).join('') + addTile
4681
4674
  : addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`,
4682
- ]);
4675
+ );
4683
4676
  }
4684
4677
 
4685
- writes.push([
4678
+ setColumnHtml(
4686
4679
  inProgressTasks,
4687
4680
  inProgress.length > 0
4688
4681
  ? inProgress.map(renderTaskCard).join('')
4689
4682
  : `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div>`,
4690
- ]);
4683
+ );
4691
4684
 
4692
- writes.push([
4685
+ setColumnHtml(
4693
4686
  completedTasks,
4694
4687
  completed.length > 0
4695
4688
  ? completed.map(renderTaskCard).join('')
4696
4689
  : `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div>`,
4697
- ]);
4698
-
4699
- // Capture rects (a forced layout read per card) only when a column will
4700
- // actually be rewritten — most SSE ticks change nothing and skip the FLIP.
4701
- const willChange = writes.some(([el, html]) => el._lastHtml !== html);
4702
- const flipRects = willChange ? captureCardRects() : null;
4703
- for (const [el, html] of writes) setColumnHtml(el, html);
4704
- if (flipRects) playCardFlip(flipRects);
4690
+ );
4705
4691
 
4706
4692
  if (selectedTaskId) {
4707
4693
  const card =
@@ -5277,7 +5263,7 @@ function sgListFiltered() {
5277
5263
  // terminal or placeholder counts. Only the full Active list can tell, so other views keep them.
5278
5264
  function sgSweepTransient() {
5279
5265
  if (sgRev === null || !sessionGroups.some((g) => g.transient)) return;
5280
- if (zenGroup || zenMode || sessionFilter !== 'active' || sessionsHasMore || sgListFiltered()) return;
5266
+ if (zenGroup || zenProject || zenMode || sessionFilter !== 'active' || sessionsHasMore || sgListFiltered()) return;
5281
5267
  const shown = new Set(getFilteredSessions().map((s) => s.id));
5282
5268
  const starting = (id) => !dismissedSessionIds.has(id) && (runningTerminals.has(id) || dispatchSpecs.has(id));
5283
5269
  const present = (m) => m.type !== 'session' || shown.has(m.ref) || starting(m.ref);
@@ -5660,7 +5646,7 @@ function sgGroupDropZone(target) {
5660
5646
  return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
5661
5647
  }
5662
5648
  // Stacking a session onto its own project block would only fold it back where it sits today.
5663
- if (dragSession && sessionProjectKey(dragSession) === path) return releasable ? { zone } : null;
5649
+ if (dragSession && sessionProjectKey(dragSession) === path) return null;
5664
5650
  return { zone, pairWith: path };
5665
5651
  }
5666
5652
 
@@ -6155,6 +6141,7 @@ function setGroupCollapsed(header, collapsed) {
6155
6141
  pinPeekKey = null;
6156
6142
  pinPeekClosedFor = currentSessionId;
6157
6143
  }
6144
+ if (projectPath === zenPeekKey) zenPeekKey = null;
6158
6145
  if (collapsed) collapsedProjectGroups.add(projectPath);
6159
6146
  else collapsedProjectGroups.delete(projectPath);
6160
6147
  header.classList.toggle('collapsed', collapsed);
@@ -6164,10 +6151,11 @@ function setGroupCollapsed(header, collapsed) {
6164
6151
  }
6165
6152
 
6166
6153
  function persistCollapsedGroups() {
6167
- store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups, ...(pinPeekKey ? [pinPeekKey] : [])]);
6154
+ store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups, ...[pinPeekKey, zenPeekKey].filter(Boolean)]);
6168
6155
  }
6169
6156
 
6170
6157
  let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
6158
+ let lastListScope = null;
6171
6159
 
6172
6160
  // Expand collapsed project sections that contain active sessions; leave the rest collapsed.
6173
6161
  // onlyNew: expand only for sessions that became active since the last call, so a section the
@@ -6224,6 +6212,14 @@ function pinnedCollapseKeys(session) {
6224
6212
  // A Pinned sub-section the user collapsed opens while the open session is in it and closes once
6225
6213
  // the user leaves that session. The peek is never saved: persistCollapsedGroups writes the key back.
6226
6214
  let pinPeekKey = null;
6215
+ // The collapsed group or project that zen opened; leaving zen collapses it again, unless the user
6216
+ // toggled it meanwhile.
6217
+ let zenPeekKey = null;
6218
+
6219
+ function setZenPeek(key) {
6220
+ if (zenPeekKey) collapsedProjectGroups.add(zenPeekKey);
6221
+ zenPeekKey = key && collapsedProjectGroups.delete(key) ? key : null;
6222
+ }
6227
6223
  let pinPeekClosedFor = null;
6228
6224
 
6229
6225
  function syncPinPeek() {
@@ -6742,7 +6738,7 @@ const SHORTCUT_TABS = [
6742
6738
  {
6743
6739
  keys: ['Ctrl', 'Shift', 'Z'],
6744
6740
  combo: true,
6745
- label: 'Zen mode (current session, or the group under the cursor)',
6741
+ label: 'Zen mode (current session, or the group or project under the cursor)',
6746
6742
  },
6747
6743
  { keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
6748
6744
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
@@ -6780,6 +6776,7 @@ const SHORTCUT_TABS = [
6780
6776
  { keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
6781
6777
  { keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
6782
6778
  { keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
6779
+ { keys: ['Ctrl', 'Alt', 'Shift', 'S'], combo: true, hubMod: true, label: 'Next session that needs you' },
6783
6780
  { keys: ['Shift', 'C'], combo: true, label: 'Copy session id' },
6784
6781
  { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Copy session name (SendMessage address)' },
6785
6782
  ],
@@ -6900,6 +6897,12 @@ function buildHelpShortcuts(groups) {
6900
6897
  for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
6901
6898
  for (const [group, side] of sides) {
6902
6899
  const row = group.rows[i];
6900
+ if (row?.say) {
6901
+ cells.push(
6902
+ `<dt class="${escapeHtml(side)}"><span class="sc-say">${escapeHtml(row.say)}</span></dt><dd class="${escapeHtml(side)}">${escapeHtml(row.label)}</dd>`,
6903
+ );
6904
+ continue;
6905
+ }
6903
6906
  const parts = row && helpKeys(row);
6904
6907
  if (!parts?.length) continue;
6905
6908
  const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
@@ -6917,10 +6920,28 @@ const helpFilter = document.getElementById('help-filter');
6917
6920
 
6918
6921
  // Hub keys do nothing outside Claude Code Hub, except the app links that the --*-url flags set.
6919
6922
  const hubRowWorks = (r) => (r.opens ? hub.can(r.opens) : hub.inHub);
6923
+ // With voice on, the hub lists the commands this app handles. A hub before SDK 1.11 has no voiceCommands.
6924
+ function voiceHelpGroup() {
6925
+ const commands = hub.voiceCommands?.() ?? [];
6926
+ if (!commands.length) return [];
6927
+ const hold = {
6928
+ keys: ['Ctrl', 'Shift', 'Space'],
6929
+ action: 'hub.voice',
6930
+ combo: true,
6931
+ label: 'Hold and say "hub", then a command',
6932
+ };
6933
+ return [{ title: 'Voice', rows: [hold, ...commands.map((c) => ({ say: `hub, ${c.say}`, label: c.help }))] }];
6934
+ }
6935
+
6920
6936
  const helpTabs = () =>
6921
6937
  SHORTCUT_TABS.map((t) =>
6922
6938
  t.hub
6923
- ? { ...t, groups: t.groups.map((g) => ({ ...g, rows: g.rows.filter(hubRowWorks) })).filter((g) => g.rows.length) }
6939
+ ? {
6940
+ ...t,
6941
+ groups: [...t.groups, ...voiceHelpGroup()]
6942
+ .map((g) => ({ ...g, rows: g.rows.filter(hubRowWorks) }))
6943
+ .filter((g) => g.rows.length),
6944
+ }
6924
6945
  : t,
6925
6946
  ).filter((t) => t.groups.length);
6926
6947
 
@@ -6930,7 +6951,9 @@ function filteredShortcutGroups(query) {
6930
6951
  .flatMap((t) => t.groups)
6931
6952
  .map((g) => ({
6932
6953
  title: g.title,
6933
- rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`.replaceAll('−', '-'), query)),
6954
+ rows: g.rows.filter((r) =>
6955
+ fuzzyMatch(`${r.label} ${r.say ?? helpKeys(r).join(' ')}`.replaceAll('−', '-'), query),
6956
+ ),
6934
6957
  }))
6935
6958
  .filter((g) => g.rows.length);
6936
6959
  }
@@ -8559,6 +8582,7 @@ function stopReview() {
8559
8582
  if (!activeReview) return;
8560
8583
  const { ctl, panelEl, hostEl } = activeReview;
8561
8584
  ctl.abort();
8585
+ cancelDictationIn(panelEl);
8562
8586
  for (const c of reviewItems()) {
8563
8587
  c.range = null;
8564
8588
  c.frameId = 0;
@@ -9049,6 +9073,7 @@ function closeReviewPop() {
9049
9073
  }
9050
9074
  reviewPopMode = null;
9051
9075
  const el = reviewPopEl();
9076
+ cancelDictationIn(el);
9052
9077
  el.className = '';
9053
9078
  el.replaceChildren();
9054
9079
  }
@@ -9102,10 +9127,11 @@ function openReviewEditor(picked, text = '') {
9102
9127
  reviewPendingHighlight?.add(picked.range);
9103
9128
  } else if (picked.inFrame) postToReviewFrame('pend');
9104
9129
  activeReview.picked = picked;
9105
- el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
9130
+ el.innerHTML = `<div class="mic-field"><textarea class="review-pop-input" rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea></div>
9106
9131
  <div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
9107
9132
  const ta = el.querySelector('textarea');
9108
9133
  ta.value = text;
9134
+ addMic(ta);
9109
9135
  const add = (comment) => addReviewComment(picked, comment);
9110
9136
  ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
9111
9137
  el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
@@ -9261,6 +9287,7 @@ function renderReviewPanel() {
9261
9287
  syncReviewSendClaims();
9262
9288
  if (!activeReview) return;
9263
9289
  const { panelEl, hostEl } = activeReview;
9290
+ cancelDictationIn(panelEl);
9264
9291
  const items = reviewItems();
9265
9292
  panelEl.hidden = !items.length;
9266
9293
  hostEl?.classList.toggle('has-review', items.length > 0);
@@ -9274,7 +9301,7 @@ function renderReviewPanel() {
9274
9301
  .map((c, i) => {
9275
9302
  const body =
9276
9303
  c === activeReview.editing?.c
9277
- ? `<textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea>`
9304
+ ? `<div class="mic-field"><textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea></div>`
9278
9305
  : `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
9279
9306
  return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
9280
9307
  <button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
@@ -9283,6 +9310,8 @@ function renderReviewPanel() {
9283
9310
  ${body}</li>`;
9284
9311
  })
9285
9312
  .join('')}</ol>`;
9313
+ const edit = panelEl.querySelector('.review-card-edit');
9314
+ if (edit) addMic(edit);
9286
9315
  }
9287
9316
 
9288
9317
  async function toastDelivery({ delivered, markdown }, noun) {
@@ -11946,21 +11975,26 @@ function renderFilterState() {
11946
11975
  document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
11947
11976
  }
11948
11977
 
11949
- // With the sidebar cursor on a group header, zen shows that group instead of the open session.
11978
+ // With the sidebar cursor on a group or project header, zen shows that group or project instead
11979
+ // of the open session.
11950
11980
  function toggleZenMode() {
11951
11981
  const leavingSessionZen = zenMode;
11952
11982
  if (zenMode) zenMode = false;
11953
- else if (zenGroup) zenGroup = null;
11983
+ else if (zenGroup || zenProject) zenGroup = zenProject = null;
11954
11984
  else {
11955
- zenGroup = kbSelectedGroup()?.id || null;
11956
- zenMode = !zenGroup;
11985
+ const el = focusZone === 'sidebar' && sessionsList.querySelector('.kb-selected');
11986
+ const sel = el && sgDragPayload(el);
11987
+ zenGroup = sel?.kind === 'group' ? sgGroupById(sel.ref)?.id || null : null;
11988
+ zenProject = sel?.kind === 'project' ? sel.ref : null;
11989
+ zenMode = !zenGroup && !zenProject;
11957
11990
  }
11991
+ setZenPeek(zenGroup ? sgKey(zenGroup) : zenProject);
11958
11992
  store.setItem(ZEN_KEY, String(zenMode));
11959
11993
  renderZenState();
11960
- if (zenMode || zenGroup) closeFilterMenu();
11994
+ if (zenMode) closeFilterMenu();
11961
11995
  renderSessions();
11962
- // The open session or the group's members may be outside the current server page/filter,
11963
- // and leaving group zen needs the filtered page back.
11996
+ // The open session or the scope's sessions may be outside the current server page/filter,
11997
+ // and leaving a scope needs the filtered page back.
11964
11998
  if (!leavingSessionZen) fetchSessions(false);
11965
11999
  }
11966
12000
 
@@ -11973,7 +12007,7 @@ function applyHubSettings(s) {
11973
12007
  function renderZenState() {
11974
12008
  const btn = document.getElementById('zen-mode-btn');
11975
12009
  if (!btn) return;
11976
- const on = zenMode || !!zenGroup;
12010
+ const on = zenMode || !!zenGroup || !!zenProject;
11977
12011
  btn.classList.toggle('active', on);
11978
12012
  btn.setAttribute('aria-pressed', String(on));
11979
12013
  btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
@@ -15591,6 +15625,7 @@ function inPageField(e) {
15591
15625
  function terminalShortcut(e, probe = false) {
15592
15626
  const ctrlAlt = hubModDown(e);
15593
15627
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
15628
+ const ctrlAltShift = e.ctrlKey && e.altKey && e.shiftKey && !e.metaKey && !e.getModifierState?.('AltGraph');
15594
15629
  // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal. An open ask card,
15595
15630
  // which sits in the terminal pane, takes them for its own text zoom.
15596
15631
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
@@ -15598,6 +15633,7 @@ function terminalShortcut(e, probe = false) {
15598
15633
  return () => adjustTerminalFontSize(Math.sign(zoom));
15599
15634
  }
15600
15635
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
15636
+ if (ctrlAltShift && e.code === 'KeyS') return nextNeedsYou;
15601
15637
  if (matchKey(e, 'KeyR') && terminalConnectSlow() && wantsTerminal() && !inPageField(e)) return reconnectTerminal;
15602
15638
  // Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
15603
15639
  if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
@@ -16622,7 +16658,7 @@ async function endAllTerminals() {
16622
16658
  // The board's own assistant (docs/kanbot.md): a claude session the server keeps hidden from the list,
16623
16659
  // shown in a popover through a second terminal frame on the board terminal's frame origin.
16624
16660
  const KANBOT_SVG =
16625
- '<svg viewBox="0 0 16 13" width="16" height="13" shape-rendering="crispEdges" aria-hidden="true">' +
16661
+ '<svg data-hub-part="kanbot" viewBox="0 0 16 13" width="16" height="13" shape-rendering="crispEdges" aria-hidden="true">' +
16626
16662
  '<path fill="#d97757" d="M3 0h10v4h3v3h-3v3H3V7H0V4h3z"/>' +
16627
16663
  '<path fill="#d97757" d="M4 10h1v3H4zM6 10h1v3H6zM9 10h1v3H9zM11 10h1v3h-1z"/>' +
16628
16664
  '<path fill="#1f1e1d" d="M5 2h1v2H5zM10 2h1v2h-1z"/></svg>';
@@ -18538,12 +18574,7 @@ function closeNewSession() {
18538
18574
  closeFolderList();
18539
18575
  closeImagePreview();
18540
18576
  clearNewSessionImages();
18541
- promptVoice.rec?.abort();
18542
- if (promptVoice.hub) {
18543
- promptVoice.hub = null;
18544
- document.getElementById('ns-mic').setAttribute('aria-pressed', 'false');
18545
- hub.invoke(HUB_DICTATE, { op: 'cancel' });
18546
- }
18577
+ cancelDictationIn(document.getElementById('new-session-modal'));
18547
18578
  }
18548
18579
 
18549
18580
  function addNewSessionImages(files) {
@@ -18836,13 +18867,27 @@ async function startNewSessionInBackground(id, v) {
18836
18867
  }
18837
18868
 
18838
18869
  const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
18839
- const promptVoice = { rec: null, hub: null };
18870
+ // One dictation at a time: { btn, rec } in the browser, { btn, insert } under the hub.
18871
+ let dictation = null;
18840
18872
  const HUB_DICTATE = 'voice.dictate';
18873
+ const MIC_HTML = `<button type="button" class="mic-btn" title="Dictate" aria-label="Dictate" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg></button>`;
18874
+
18875
+ // Puts a mic after a textarea that sits in a `.mic-field`. No browser speech input, no mic.
18876
+ function addMic(field, onError = (msg) => showToast(msg, 'error')) {
18877
+ if (!SpeechRecognitionApi) return;
18878
+ field.insertAdjacentHTML('afterend', MIC_HTML);
18879
+ const btn = field.nextElementSibling;
18880
+ // mousedown would blur the textarea and lose the caret the dictation inserts at.
18881
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
18882
+ btn.addEventListener('click', (e) => {
18883
+ e.stopPropagation();
18884
+ toggleDictation(field, btn, onError);
18885
+ });
18886
+ }
18841
18887
 
18842
18888
  // Replaces the selection the field had when dictation began, so interim results rewrite
18843
- // themselves in place instead of piling up.
18844
- function promptInserter() {
18845
- const field = document.getElementById('ns-prompt');
18889
+ // themselves in place instead of piling up. Fires `input`, as typing would.
18890
+ function fieldInserter(field) {
18846
18891
  const before = field.value.slice(0, field.selectionStart);
18847
18892
  const after = field.value.slice(field.selectionEnd);
18848
18893
  const lead = before && !/\s$/.test(before) ? ' ' : '';
@@ -18850,50 +18895,63 @@ function promptInserter() {
18850
18895
  field.value = before + lead + text + after;
18851
18896
  const caret = before.length + lead.length + text.length;
18852
18897
  field.setSelectionRange(caret, caret);
18853
- renderNewSessionForm();
18898
+ field.dispatchEvent(new Event('input', { bubbles: true }));
18854
18899
  };
18855
18900
  }
18856
18901
 
18857
- function togglePromptVoice() {
18858
- if (hub.can(HUB_DICTATE)) return toggleHubDictation();
18859
- if (promptVoice.rec) return promptVoice.rec.stop();
18860
- const field = document.getElementById('ns-prompt');
18861
- const btn = document.getElementById('ns-mic');
18862
- const insert = promptInserter();
18902
+ function toggleDictation(field, btn, onError) {
18903
+ if (dictation?.btn !== btn) cancelDictation();
18904
+ if (hub.can(HUB_DICTATE)) return toggleHubDictation(field, btn, onError);
18905
+ if (dictation) return dictation.rec.stop();
18906
+ const insert = fieldInserter(field);
18863
18907
  const rec = new SpeechRecognitionApi();
18864
18908
  rec.lang = navigator.language;
18865
18909
  rec.interimResults = true;
18866
18910
  rec.onresult = (e) => insert(Array.from(e.results, (r) => r[0].transcript).join(''));
18867
18911
  rec.onerror = (e) => {
18868
18912
  if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
18869
- setNewSessionError('Microphone access is blocked for this page.');
18870
- else if (e.error !== 'no-speech' && e.error !== 'aborted') setNewSessionError(`Dictation failed: ${e.error}`);
18913
+ onError('Microphone access is blocked for this page.');
18914
+ else if (e.error !== 'no-speech' && e.error !== 'aborted') onError(`Dictation failed: ${e.error}`);
18871
18915
  };
18872
18916
  rec.onend = () => {
18873
- promptVoice.rec = null;
18874
18917
  btn.setAttribute('aria-pressed', 'false');
18918
+ if (dictation?.rec === rec) dictation = null;
18875
18919
  };
18876
- promptVoice.rec = rec;
18920
+ dictation = { btn, rec };
18877
18921
  btn.setAttribute('aria-pressed', 'true');
18878
18922
  field.focus();
18879
18923
  rec.start();
18880
18924
  }
18881
18925
 
18882
18926
  // Under a hub with voice on, the mic uses the hub's speech provider: the text arrives once, on the second click.
18883
- async function toggleHubDictation() {
18884
- const btn = document.getElementById('ns-mic');
18885
- const op = promptVoice.hub ? 'stop' : 'start';
18886
- const insert = promptVoice.hub ?? promptInserter();
18887
- promptVoice.hub = op === 'start' ? insert : null;
18888
- btn.setAttribute('aria-pressed', String(op === 'start'));
18889
- document.getElementById('ns-prompt').focus();
18890
- const r = await hub.invoke(HUB_DICTATE, { op });
18927
+ async function toggleHubDictation(field, btn, onError) {
18928
+ const running = dictation?.insert;
18929
+ const insert = running ?? fieldInserter(field);
18930
+ dictation = running ? null : { btn, insert };
18931
+ btn.setAttribute('aria-pressed', String(!running));
18932
+ field.focus();
18933
+ const r = await hub.invoke(HUB_DICTATE, { op: running ? 'stop' : 'start' });
18891
18934
  if (!r.ok) {
18892
- promptVoice.hub = null;
18935
+ if (dictation?.insert === insert) dictation = null;
18893
18936
  btn.setAttribute('aria-pressed', 'false');
18894
- return setNewSessionError(`Dictation failed: ${r.reason}`);
18937
+ return onError(`Dictation failed: ${r.reason}`);
18895
18938
  }
18896
- if (op === 'stop' && r.text) insert(r.text);
18939
+ if (running && r.text && field.isConnected) insert(r.text);
18940
+ }
18941
+
18942
+ // Drops the running dictation without inserting text.
18943
+ function cancelDictation() {
18944
+ if (!dictation) return;
18945
+ const { btn, rec } = dictation;
18946
+ dictation = null;
18947
+ btn.setAttribute('aria-pressed', 'false');
18948
+ if (rec) rec.abort();
18949
+ else hub.invoke(HUB_DICTATE, { op: 'cancel' });
18950
+ }
18951
+
18952
+ // For a field about to close or render again.
18953
+ function cancelDictationIn(root) {
18954
+ if (dictation && root.contains(dictation.btn)) cancelDictation();
18897
18955
  }
18898
18956
 
18899
18957
  function pickFolderOption(i) {
@@ -18945,14 +19003,8 @@ function initNewSession() {
18945
19003
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
18946
19004
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
18947
19005
  }
18948
- if (SpeechRecognitionApi) {
18949
- const mic = document.getElementById('ns-mic');
18950
- mic.hidden = false;
18951
- // mousedown would blur the textarea and lose the caret the dictation inserts at.
18952
- mic.addEventListener('mousedown', (e) => e.preventDefault());
18953
- mic.addEventListener('click', togglePromptVoice);
18954
- }
18955
19006
  const prompt = document.getElementById('ns-prompt');
19007
+ addMic(prompt, setNewSessionError);
18956
19008
  const promptField = prompt.closest('.ns-prompt-field');
18957
19009
  prompt.addEventListener('paste', (e) => {
18958
19010
  const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
@@ -19390,13 +19442,16 @@ hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session)
19390
19442
  hub.handle('session.find', (p) => findSessionFromHub(p));
19391
19443
  hub.handle('session.paste', (p) => pasteFromHub(p));
19392
19444
  hub.handle('task.add', (p) => addTaskFromHub(p));
19445
+ hub.handle('session.approve', () => answerWaitingFromHub('allow'));
19446
+ hub.handle('session.deny', (p) => answerWaitingFromHub('deny', p.message));
19447
+ hub.handle('session.next', () => nextNeedsYou());
19393
19448
  hub.handle('task.live', (p) => liveTaskFromHub(p));
19394
19449
  // After pending session.live calls, so the hub's clear of words typed into the terminal lands there, not in the dialog.
19395
19450
  hub.handle('session.new', (p) => {
19396
- hubNewSession = liveChain.then(() => newSessionFromHub(p)).catch(() => {});
19451
+ hubNewSession = liveChain.then(() => newSessionFromHub(p)).catch((e) => console.error('[hub] session.new', e));
19397
19452
  });
19398
19453
  hub.handle('session.live', (p) => {
19399
- liveChain = liveChain.then(() => liveFromHub(p)).catch(() => {});
19454
+ liveChain = liveChain.then(() => liveFromHub(p)).catch((e) => console.error('[hub] session.live', e));
19400
19455
  });
19401
19456
 
19402
19457
  // Action params are strings, so flags come as "true" and `args` as claude args split on spaces
@@ -19490,17 +19545,88 @@ function findSessionFromHub({ name }) {
19490
19545
  openSessionPicker({ query: name?.trim() ?? '', ranked: ranked.length ? ranked.map((r) => r.s) : null });
19491
19546
  }
19492
19547
 
19548
+ function answerWaitingFromHub(behavior, message) {
19549
+ if (!isWaitingAnswerable()) return showToast('Nothing to answer on this session', 'info');
19550
+ if (currentWaiting.kind === 'question') {
19551
+ showWaitingDetail();
19552
+ return showToast('Pick the answer on the card', 'info');
19553
+ }
19554
+ if (behavior === 'deny') return denyWaiting(message);
19555
+ return respondWaiting({ behavior });
19556
+ }
19557
+
19558
+ function needsYouQueue() {
19559
+ const live = sessions.filter((s) => (!s.placeholder || trustWaiting.has(s.id)) && !dismissedSessionIds.has(s.id));
19560
+ return [...live.filter(isWaitingSession), ...live.filter((s) => !isWaitingSession(s) && s.unread)];
19561
+ }
19562
+
19563
+ function nextNeedsYou() {
19564
+ const queue = needsYouQueue();
19565
+ const next = queue[(queue.findIndex((s) => s.id === currentSessionId) + 1) % queue.length];
19566
+ if (!next || next.id === currentSessionId) return showToast('No other session needs you', 'info');
19567
+ if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
19568
+ return revealSession(next.id);
19569
+ }
19570
+
19571
+ // voice.context tells the hub's voice help what fits now: the open session's ask as the title, and per
19572
+ // action false to hide its row or a help text. The agent footer renders every second, so the ask is
19573
+ // parsed once per marker.
19574
+ let publishedVoiceContext = '';
19575
+ let askTitle = { id: null, title: null };
19576
+
19577
+ function waitingTitle() {
19578
+ if (!isWaitingAnswerable() || currentWaiting.kind === 'question') return null;
19579
+ if (askTitle.id === currentWaiting.id) return askTitle.title;
19580
+ const tool = currentWaiting.toolName || 'unknown';
19581
+ const detail = deriveWaitingDetail(tool, parseWaitingInput());
19582
+ const title = isPlanAsk(currentWaiting.kind, tool)
19583
+ ? detail || getWaitingPill(currentWaiting.kind, tool)
19584
+ : `${tool} ${detail}`.trim();
19585
+ askTitle = { id: currentWaiting.id, title };
19586
+ return title;
19587
+ }
19588
+
19589
+ function publishVoiceContext() {
19590
+ const title = waitingTitle();
19591
+ const answerable = !!title;
19592
+ const others = needsYouQueue().filter((s) => s.id !== currentSessionId).length;
19593
+ const payload = {
19594
+ title,
19595
+ rows: {
19596
+ 'session.approve': answerable,
19597
+ 'session.deny': answerable,
19598
+ 'session.next': others > 0 && `${others} more ${others === 1 ? 'needs' : 'need'} you`,
19599
+ },
19600
+ };
19601
+ const json = JSON.stringify(payload);
19602
+ if (json === publishedVoiceContext) return;
19603
+ publishedVoiceContext = json;
19604
+ hub.publish('voice.context', payload);
19605
+ }
19606
+
19493
19607
  // Voice while the New session dialog is open is dictation into its prompt; Enter on Start sends it.
19494
19608
  function voiceIntoNewSession(text) {
19495
- promptInserter()(text.trim());
19609
+ fieldInserter(document.getElementById('ns-prompt'))(text.trim());
19496
19610
  if (ns.folder) document.getElementById('ns-start').focus();
19497
19611
  }
19498
19612
 
19499
- // The hub's voice control sends dictated text here; with no session named, it goes to the one on screen.
19613
+ // The hub's voice control sends dictated text here; with no session named, it goes to the one on screen,
19614
+ // else to the open session when its terminal runs behind the board or a pane.
19500
19615
  const newSessionOpen = () => document.getElementById('new-session-modal').classList.contains('visible');
19501
- const hubTargetSession = (session) => session || (viewMode === 'session' && termState.shown && termState.sessionId);
19616
+ const hubTargetSession = (session) =>
19617
+ session ||
19618
+ (viewMode === 'session' &&
19619
+ (termState.shown ? termState.sessionId : runningTerminals.has(currentSessionId) && currentSessionId));
19502
19620
  const noSessionToast = (text) =>
19503
19621
  showToast('No session on screen', 'info', { label: 'Copy', onClick: () => copyText(text, 'text') }, text);
19622
+ function typedInBackgroundToast(id) {
19623
+ if (termState.shown && termState.sessionId === id) return;
19624
+ const s = sessions.find((x) => x.id === id);
19625
+ showToast(`Typed into ${s ? sessionDisplayName(s) : id.slice(0, 8)}`, 'success', {
19626
+ label: 'Show',
19627
+ onClick: () => showSessionTerminal(id),
19628
+ });
19629
+ }
19504
19630
 
19505
19631
  async function pasteFromHub({ session, text, submit }) {
19506
19632
  if (!text) return;
@@ -19511,7 +19637,8 @@ async function pasteFromHub({ session, text, submit }) {
19511
19637
  text,
19512
19638
  submit: submit === 'true',
19513
19639
  });
19514
- if (!res.ok) showToast((await res.json().catch(() => ({}))).error || `Paste failed (${res.status})`, 'error');
19640
+ if (res.ok) typedInBackgroundToast(id);
19641
+ else showToast((await res.json().catch(() => ({}))).error || `Paste failed (${res.status})`, 'error');
19515
19642
  }
19516
19643
 
19517
19644
  // session.live: one key hold sends the words so far many times, then once with final. Calls run in
@@ -19538,9 +19665,9 @@ async function liveFromHub({ session, text, final, submit, cancel }) {
19538
19665
  const hold = liveText;
19539
19666
  if (done) liveText = { insert: null, typed: '', at: 0 };
19540
19667
  if (!session && newSessionOpen()) {
19541
- hold.insert ??= promptInserter();
19542
- hold.insert(text.trim());
19543
19668
  const prompt = document.getElementById('ns-prompt');
19669
+ hold.insert ??= fieldInserter(prompt);
19670
+ hold.insert(text.trim());
19544
19671
  prompt.scrollTop = prompt.scrollHeight;
19545
19672
  setNewSessionVoice(!done);
19546
19673
  if (cancel === 'close') closeNewSession();
@@ -19557,18 +19684,24 @@ async function liveFromHub({ session, text, final, submit, cancel }) {
19557
19684
  text,
19558
19685
  submit: done && submit === 'true',
19559
19686
  });
19560
- if (res.ok) hold.typed = text;
19561
- else if (done) showToast((await res.json().catch(() => ({}))).error || `Typing failed (${res.status})`, 'error');
19687
+ if (res.ok) {
19688
+ hold.typed = text;
19689
+ if (done && text) typedInBackgroundToast(id);
19690
+ } else if (done) showToast((await res.json().catch(() => ({}))).error || `Typing failed (${res.status})`, 'error');
19562
19691
  }
19563
19692
 
19564
19693
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
19694
+ // voice.context shares the timer; each publisher skips a payload it already sent.
19565
19695
  let publishTimer = 0;
19566
19696
  let publishedSessionId;
19567
19697
  let cliOpenedId = null;
19568
19698
 
19569
- function schedulePublishSession() {
19699
+ function schedulePublish() {
19570
19700
  clearTimeout(publishTimer);
19571
- publishTimer = setTimeout(publishSession, 150);
19701
+ publishTimer = setTimeout(() => {
19702
+ publishSession();
19703
+ publishVoiceContext();
19704
+ }, 150);
19572
19705
  }
19573
19706
 
19574
19707
  function publishSession() {
@@ -19663,3 +19796,22 @@ hub.subscribe('project.changed', (p) => {
19663
19796
  filterByProject(dirPath);
19664
19797
  });
19665
19798
  // #endregion HUB_INTEGRATION
19799
+
19800
+ // #region MOD_SURFACE
19801
+ // Hub mods change the board through the names in mod-surface.json (docs/hub-contract.md, Mods).
19802
+ // With no mod installed, or an older hub's SDK that has no ClaudeHub.mod, the renders stay unwrapped.
19803
+ if (ClaudeHub.mod?.list.length) {
19804
+ for (const [name, event] of [
19805
+ ['renderSessions', 'sessions.rendered'],
19806
+ ['renderKanban', 'board.rendered'],
19807
+ ['renderActivityChip', 'activity.rendered'],
19808
+ ]) {
19809
+ const render = window[name];
19810
+ window[name] = function emitAfterRender(...args) {
19811
+ const out = render.apply(this, args);
19812
+ ClaudeHub.mod.emit(event, out);
19813
+ return out;
19814
+ };
19815
+ }
19816
+ }
19817
+ // #endregion MOD_SURFACE
package/public/index.html CHANGED
@@ -65,7 +65,7 @@
65
65
  <a href="#main-content" class="skip-link">Skip to main content</a>
66
66
  <div class="app">
67
67
  <!-- Sidebar -->
68
- <aside class="sidebar">
68
+ <aside class="sidebar" data-hub-part="sidebar">
69
69
  <header class="sidebar-header">
70
70
  <div id="activity-chips" class="activity-chips"></div>
71
71
  <button id="new-session-btn" class="new-session-pill" onclick="openNewSession()" title="New session (Ctrl+Alt+N)" hidden>+ New</button>
@@ -185,7 +185,7 @@
185
185
  </button>
186
186
  </div>
187
187
  </div>
188
- <div id="sessions-list" class="sessions-list" aria-busy="true">
188
+ <div id="sessions-list" class="sessions-list" data-hub-part="sessions" aria-busy="true">
189
189
  <div class="session-skeleton"><span></span><span></span></div>
190
190
  <div class="session-skeleton"><span></span><span></span></div>
191
191
  <div class="session-skeleton"><span></span><span></span></div>
@@ -193,7 +193,7 @@
193
193
  </div>
194
194
  </div>
195
195
 
196
- <div class="sidebar-footer" id="sidebar-footer"></div>
196
+ <div class="sidebar-footer" id="sidebar-footer" data-hub-part="sidebar-footer"></div>
197
197
  <div class="sidebar-resize-handle" id="sidebar-resize"></div>
198
198
  </aside>
199
199
 
@@ -224,7 +224,7 @@
224
224
  </div>
225
225
  <div class="idle-glow" aria-hidden="true"><span></span><span></span></div>
226
226
  <div class="idle-content">
227
- <div class="idle-clawd" aria-hidden="true">
227
+ <div class="idle-clawd" data-hub-part="idle-mascot" aria-hidden="true">
228
228
  <svg class="clawd" viewBox="1 0 16 10" shape-rendering="crispEdges"><use href="#clawd-shape"/></svg>
229
229
  </div>
230
230
  <p class="idle-title">Select a session to view tasks</p>
@@ -240,8 +240,8 @@
240
240
  </div>
241
241
  <div class="view-actions">
242
242
  <div class="view-progress">
243
- <div class="progress-bar">
244
- <div id="progress-bar" class="progress-fill" style="width: 0%"></div>
243
+ <div class="progress-bar" data-hub-part="progress">
244
+ <div id="progress-bar" class="progress-fill" data-hub-part="progress-fill" style="width: 0%"></div>
245
245
  </div>
246
246
  <span id="progress-percent" class="progress-text">0% done</span>
247
247
  </div>
@@ -314,7 +314,7 @@
314
314
 
315
315
  <div id="pane-split" class="pane-split">
316
316
  <div id="board-side" class="board-side">
317
- <div class="kanban" id="main-content">
317
+ <div class="kanban" id="main-content" data-hub-part="board">
318
318
  <div id="owner-filter-bar" class="owner-filter-bar">
319
319
  <select id="owner-filter" class="filter-dropdown" onchange="filterByOwner(this.value)" aria-label="Filter by team member">
320
320
  <option value="">All Members</option>
@@ -341,7 +341,7 @@
341
341
  <div class="kanban-column" aria-label="Completed tasks">
342
342
  <div class="column-header">
343
343
  <span class="column-dot completed"></span>
344
- <span class="column-title completed">Completed</span>
344
+ <span class="column-title completed" data-hub-part="done-title">Completed</span>
345
345
  <span id="completed-count" class="column-count completed">0</span>
346
346
  </div>
347
347
  <div id="completed-tasks" class="column-tasks" role="list" data-status="completed" ondragover="onColumnDragOver(event)" ondragleave="onColumnDragLeave(event)" ondrop="onColumnDrop(event)"></div>
@@ -841,11 +841,8 @@
841
841
  </div>
842
842
  <div class="ns-row ns-extra">
843
843
  <label class="ns-label" for="ns-prompt">Prompt</label>
844
- <div class="ns-field ns-prompt-field">
844
+ <div class="ns-field ns-prompt-field mic-field">
845
845
  <textarea id="ns-prompt" class="form-input" rows="4" placeholder="Optional. Sent as the first message. Paste or drop images to attach."></textarea>
846
- <button type="button" id="ns-mic" class="ns-mic" title="Dictate" aria-label="Dictate" aria-pressed="false" hidden>
847
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>
848
- </button>
849
846
  <div id="ns-images" class="ns-images" hidden></div>
850
847
  </div>
851
848
  </div>
package/public/style.css CHANGED
@@ -8829,6 +8829,12 @@ body.light .msg-assistant .msg-text {
8829
8829
  border-radius: 5px;
8830
8830
  }
8831
8831
 
8832
+ .shortcuts .sc-say {
8833
+ font-family: var(--font-mono, monospace);
8834
+ font-size: 12px;
8835
+ color: var(--text-primary);
8836
+ }
8837
+
8832
8838
  .shortcuts .sc-plus,
8833
8839
  .shortcuts .sc-or {
8834
8840
  color: var(--text-muted);
@@ -9108,7 +9114,7 @@ select.form-input option:checked {
9108
9114
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
9109
9115
  }
9110
9116
 
9111
- #review-pop textarea,
9117
+ .review-pop-input,
9112
9118
  .review-card-edit {
9113
9119
  width: 100%;
9114
9120
  box-sizing: border-box;
@@ -9183,8 +9189,9 @@ select.form-input option:checked {
9183
9189
  gap: 6px;
9184
9190
  }
9185
9191
 
9186
- #review-pop button,
9187
- .review-panel button {
9192
+ /* :where() keeps the specificity as it was, so the button.review-* rules above still win. */
9193
+ #review-pop button:not(:where(.mic-btn)),
9194
+ .review-panel button:not(:where(.mic-btn)) {
9188
9195
  padding: 3px 10px;
9189
9196
  font: inherit;
9190
9197
  color: var(--text-primary);
@@ -9807,15 +9814,15 @@ select.form-input option:checked {
9807
9814
  max-height: 50vh;
9808
9815
  }
9809
9816
 
9810
- .ns-prompt-field {
9817
+ .mic-field {
9811
9818
  position: relative;
9812
9819
  }
9813
9820
 
9814
- .ns-prompt-field:has(.ns-mic:not([hidden])) textarea {
9821
+ .mic-field:has(.mic-btn) textarea {
9815
9822
  padding-right: 36px;
9816
9823
  }
9817
9824
 
9818
- .ns-mic {
9825
+ .mic-btn {
9819
9826
  position: absolute;
9820
9827
  top: 6px;
9821
9828
  right: 6px;
@@ -9832,33 +9839,29 @@ select.form-input option:checked {
9832
9839
  cursor: pointer;
9833
9840
  }
9834
9841
 
9835
- .ns-mic[hidden] {
9836
- display: none;
9837
- }
9838
-
9839
- .ns-mic svg {
9842
+ .mic-btn svg {
9840
9843
  width: 15px;
9841
9844
  height: 15px;
9842
9845
  }
9843
9846
 
9844
- .ns-mic:hover {
9847
+ .mic-btn:hover {
9845
9848
  color: var(--text-primary);
9846
9849
  background: var(--bg-hover);
9847
9850
  }
9848
9851
 
9849
- .ns-mic[aria-pressed='true'] {
9852
+ .mic-btn[aria-pressed='true'] {
9850
9853
  color: var(--danger);
9851
- animation: ns-mic-pulse 1.2s ease-in-out infinite;
9854
+ animation: mic-pulse 1.2s ease-in-out infinite;
9852
9855
  }
9853
9856
 
9854
- @keyframes ns-mic-pulse {
9857
+ @keyframes mic-pulse {
9855
9858
  50% {
9856
9859
  opacity: 0.45;
9857
9860
  }
9858
9861
  }
9859
9862
 
9860
9863
  @media (prefers-reduced-motion: reduce) {
9861
- .ns-mic[aria-pressed='true'] {
9864
+ .mic-btn[aria-pressed='true'] {
9862
9865
  animation: none;
9863
9866
  }
9864
9867
  }
@@ -10754,49 +10757,6 @@ pre.mermaid[data-mermaid-error]::before {
10754
10757
  /* #endregion */
10755
10758
 
10756
10759
  /* #region ANIMATIONS */
10757
- /* No `to` and fill backwards: the card ends on its own opacity (.completed, .dim), not on 1. */
10758
- @keyframes fadeSlideIn {
10759
- from {
10760
- opacity: 0;
10761
- transform: translateY(8px);
10762
- }
10763
- }
10764
-
10765
- .column-tasks .task-card {
10766
- animation: fadeSlideIn 150ms ease-out backwards;
10767
- }
10768
-
10769
- .column-tasks .task-card:nth-child(1) {
10770
- animation-delay: 0ms;
10771
- }
10772
- .column-tasks .task-card:nth-child(2) {
10773
- animation-delay: 30ms;
10774
- }
10775
- .column-tasks .task-card:nth-child(3) {
10776
- animation-delay: 60ms;
10777
- }
10778
- .column-tasks .task-card:nth-child(4) {
10779
- animation-delay: 90ms;
10780
- }
10781
- .column-tasks .task-card:nth-child(5) {
10782
- animation-delay: 120ms;
10783
- }
10784
- .column-tasks .task-card:nth-child(6) {
10785
- animation-delay: 150ms;
10786
- }
10787
- .column-tasks .task-card:nth-child(7) {
10788
- animation-delay: 180ms;
10789
- }
10790
- .column-tasks .task-card:nth-child(8) {
10791
- animation-delay: 210ms;
10792
- }
10793
- .column-tasks .task-card:nth-child(9) {
10794
- animation-delay: 240ms;
10795
- }
10796
- .column-tasks .task-card:nth-child(10) {
10797
- animation-delay: 270ms;
10798
- }
10799
-
10800
10760
  /* Connection status breathing */
10801
10761
  @keyframes breathe {
10802
10762
  0%,
@@ -11135,7 +11095,8 @@ pre.mermaid[data-mermaid-error]::before {
11135
11095
  outline-offset: -1px;
11136
11096
  }
11137
11097
 
11138
- .session-group-header.sg-zen {
11098
+ .session-group-header.sg-zen,
11099
+ .project-group-header.sg-zen {
11139
11100
  color: var(--accent-text);
11140
11101
  }
11141
11102
 
@@ -1,4 +1,4 @@
1
- // claude-hub-sdk 1.9.0 (sha256 537745aa3415). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
1
+ // claude-hub-sdk 1.11.0 (sha256 841f6c771631). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
2
  // Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
3
3
  // stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
4
4
  // client.js and stub.js, so an app still loads one file.
@@ -94,6 +94,7 @@ if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys
94
94
  forwards: () => false,
95
95
  forwardCombos: () => [],
96
96
  keyLabel: () => null,
97
+ voiceCommands: () => [],
97
98
  closeGuard() {},
98
99
  openExternal(url) {
99
100
  win.open(url, '_blank', 'noopener');
@@ -103,7 +104,8 @@ if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys
103
104
  return hub;
104
105
  }
105
106
 
106
- return { connect, comboOf };
107
+ const mod = { list: [], wrap: () => false, on: () => off, emit() {}, send: () => false };
108
+ return { connect, comboOf, mod };
107
109
  }
108
110
 
109
111
  if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };