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 +5 -2
- package/mod-surface.json +38 -0
- package/package.json +2 -1
- package/public/app.js +315 -163
- package/public/index.html +9 -12
- package/public/style.css +21 -60
- package/public/vendor/claude-hub-sdk.js +4 -2
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",
|
package/mod-surface.json
ADDED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
339
|
-
|
|
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 = !
|
|
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
|
|
357
|
-
filter:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
(
|
|
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 (
|
|
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,
|
|
3880
|
-
(t.description && fuzzyMatch(t.description,
|
|
3881
|
-
(t.activeForm && fuzzyMatch(t.activeForm,
|
|
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(
|
|
3907
|
+
const groupMatchIds = sgSearchMatchIds(search);
|
|
3886
3908
|
const matchesSearch = (s) =>
|
|
3887
|
-
(s.name && fuzzyMatch(s.name,
|
|
3888
|
-
(s.id && fuzzyMatch(s.id,
|
|
3889
|
-
(s.project && fuzzyMatch(s.project,
|
|
3890
|
-
(s.description && fuzzyMatch(s.description,
|
|
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
|
-
!
|
|
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 (
|
|
3981
|
-
|
|
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 ||
|
|
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 && !
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, ...
|
|
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
|
-
? {
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
11956
|
-
|
|
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
|
|
11994
|
+
if (zenMode) closeFilterMenu();
|
|
11961
11995
|
renderSessions();
|
|
11962
|
-
// The open session or the
|
|
11963
|
-
// and leaving
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18898
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
18854
18899
|
};
|
|
18855
18900
|
}
|
|
18856
18901
|
|
|
18857
|
-
function
|
|
18858
|
-
if (
|
|
18859
|
-
if (
|
|
18860
|
-
|
|
18861
|
-
const
|
|
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
|
-
|
|
18870
|
-
else if (e.error !== 'no-speech' && e.error !== 'aborted')
|
|
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
|
-
|
|
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
|
|
18885
|
-
const
|
|
18886
|
-
|
|
18887
|
-
|
|
18888
|
-
|
|
18889
|
-
|
|
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
|
-
|
|
18935
|
+
if (dictation?.insert === insert) dictation = null;
|
|
18893
18936
|
btn.setAttribute('aria-pressed', 'false');
|
|
18894
|
-
return
|
|
18937
|
+
return onError(`Dictation failed: ${r.reason}`);
|
|
18895
18938
|
}
|
|
18896
|
-
if (
|
|
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
|
-
|
|
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) =>
|
|
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 (
|
|
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)
|
|
19561
|
-
|
|
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
|
|
19699
|
+
function schedulePublish() {
|
|
19570
19700
|
clearTimeout(publishTimer);
|
|
19571
|
-
publishTimer = setTimeout(
|
|
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
|
-
|
|
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
|
-
|
|
9187
|
-
|
|
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
|
-
.
|
|
9817
|
+
.mic-field {
|
|
9811
9818
|
position: relative;
|
|
9812
9819
|
}
|
|
9813
9820
|
|
|
9814
|
-
.
|
|
9821
|
+
.mic-field:has(.mic-btn) textarea {
|
|
9815
9822
|
padding-right: 36px;
|
|
9816
9823
|
}
|
|
9817
9824
|
|
|
9818
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
9847
|
+
.mic-btn:hover {
|
|
9845
9848
|
color: var(--text-primary);
|
|
9846
9849
|
background: var(--bg-hover);
|
|
9847
9850
|
}
|
|
9848
9851
|
|
|
9849
|
-
.
|
|
9852
|
+
.mic-btn[aria-pressed='true'] {
|
|
9850
9853
|
color: var(--danger);
|
|
9851
|
-
animation:
|
|
9854
|
+
animation: mic-pulse 1.2s ease-in-out infinite;
|
|
9852
9855
|
}
|
|
9853
9856
|
|
|
9854
|
-
@keyframes
|
|
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
|
-
.
|
|
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.
|
|
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
|
-
|
|
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 };
|