claude-code-kanban 7.3.0 → 7.4.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/lib/terminal.js +20 -21
- package/package.json +1 -1
- package/public/app.js +166 -103
- package/public/index.html +10 -7
- package/public/style.css +9 -8
package/lib/terminal.js
CHANGED
|
@@ -23,7 +23,7 @@ const priority = require('./priority');
|
|
|
23
23
|
|
|
24
24
|
const WS_PATH = '/api/terminal/ws';
|
|
25
25
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
|
-
const MODES = new Set(['auto', 'resume', '
|
|
26
|
+
const MODES = new Set(['auto', 'resume', 'shell', 'new', 'pick']);
|
|
27
27
|
const PICK_POLL_MS = 1000;
|
|
28
28
|
// How long before the PTY a registry entry may claim to have started (clock skew between the two).
|
|
29
29
|
const PICK_START_SLACK_MS = 2000;
|
|
@@ -59,7 +59,6 @@ const RESTORE_GAP_MS = 2000;
|
|
|
59
59
|
// The hub stops cck with `taskkill /f /t`, which can deliver the PTY exits to cck a moment
|
|
60
60
|
// before cck itself dies. The delay keeps that teardown out of the saved list.
|
|
61
61
|
const SAVE_DELAY_MS = 1000;
|
|
62
|
-
// A fork runs under its parent's id until claude names the new one, so it has no id to resume.
|
|
63
62
|
const RESTORABLE_MODES = new Set(['resume', 'new']);
|
|
64
63
|
// Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
|
|
65
64
|
const CLAUDE_EXIT_POLL_MS = 5000;
|
|
@@ -177,10 +176,11 @@ function shellArgs(shell, claudeArgs) {
|
|
|
177
176
|
|
|
178
177
|
function claudeArgsFor(mode, id, spec) {
|
|
179
178
|
if (mode === 'shell') return null;
|
|
180
|
-
if (mode === 'fork') return ['--resume', id, '--fork-session'];
|
|
181
179
|
if (mode === 'pick') return ['--resume'];
|
|
182
180
|
if (mode === 'new') {
|
|
183
|
-
|
|
181
|
+
// claude takes --session-id with --resume only together with --fork-session.
|
|
182
|
+
const fork = spec.forkOf ? ['--resume', spec.forkOf, '--fork-session'] : [];
|
|
183
|
+
const args = [...fork, '--session-id', id, ...(spec.extraArgs || [])];
|
|
184
184
|
if (spec.name) args.push('--name', spec.name);
|
|
185
185
|
if (spec.model) args.push('--model', spec.model);
|
|
186
186
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
@@ -203,7 +203,9 @@ function extraArgsError(args) {
|
|
|
203
203
|
|
|
204
204
|
// Returns the new-session options from a hello, or a string naming the bad field.
|
|
205
205
|
function parseNewSpec(msg) {
|
|
206
|
-
|
|
206
|
+
const forkOf = msg.forkOf ?? null;
|
|
207
|
+
if (forkOf !== null && (typeof forkOf !== 'string' || !UUID_RE.test(forkOf))) return 'session to fork';
|
|
208
|
+
if (!forkOf && (typeof msg.cwd !== 'string' || !msg.cwd)) return 'folder';
|
|
207
209
|
const name = typeof msg.name === 'string' ? msg.name.trim() : '';
|
|
208
210
|
if (name && !NAME_RE.test(name)) return 'name';
|
|
209
211
|
const worktree = msg.worktree ?? false;
|
|
@@ -222,7 +224,8 @@ function parseNewSpec(msg) {
|
|
|
222
224
|
const extraArgs = msg.extraArgs ?? [];
|
|
223
225
|
const bad = extraArgsError(extraArgs);
|
|
224
226
|
if (bad) return bad;
|
|
225
|
-
|
|
227
|
+
if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
|
|
228
|
+
return { cwd: forkOf ? null : msg.cwd, forkOf, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
function samePath(a, b) {
|
|
@@ -489,13 +492,7 @@ function createTerminalService(o) {
|
|
|
489
492
|
if (s.claudeExitedAt) return null;
|
|
490
493
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
491
494
|
const live = registry || o.liveSessions();
|
|
492
|
-
|
|
493
|
-
if (own || s.mode !== 'fork') {
|
|
494
|
-
s.claudePid = own?.pid;
|
|
495
|
-
return s.claudePid;
|
|
496
|
-
}
|
|
497
|
-
const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
|
|
498
|
-
s.claudePid = findPickProcess(live, s, claimed);
|
|
495
|
+
s.claudePid = ownEntry(live, s, s.startedAt)?.pid;
|
|
499
496
|
return s.claudePid;
|
|
500
497
|
}
|
|
501
498
|
|
|
@@ -578,6 +575,7 @@ function createTerminalService(o) {
|
|
|
578
575
|
const s = {
|
|
579
576
|
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
580
577
|
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
578
|
+
forkOf: spec?.forkOf || null,
|
|
581
579
|
};
|
|
582
580
|
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
583
581
|
else if (spec?.prompt) queueText(s, spec.prompt, true);
|
|
@@ -730,14 +728,16 @@ function createTerminalService(o) {
|
|
|
730
728
|
return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
|
|
731
729
|
}
|
|
732
730
|
}
|
|
733
|
-
|
|
731
|
+
// `claude --resume` finds a fork's parent only from the parent's own project folder.
|
|
732
|
+
const [known, forkCwd] = await Promise.all([o.resolveCwd(id), spec?.forkOf ? o.resolveCwd(spec.forkOf) : null]);
|
|
734
733
|
let cwd = known;
|
|
735
734
|
if (mode === 'new' || mode === 'pick') {
|
|
736
735
|
if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
|
|
737
|
-
|
|
736
|
+
cwd = spec?.forkOf ? forkCwd : msg.cwd;
|
|
737
|
+
if (spec?.forkOf && cwd === null) return { code: 4004, status: 404, error: 'unknown session to fork' };
|
|
738
|
+
if (!spec?.forkOf && (typeof cwd !== 'string' || !(await o.isAllowedFolder(cwd)))) {
|
|
738
739
|
return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
|
|
739
740
|
}
|
|
740
|
-
cwd = msg.cwd;
|
|
741
741
|
} else if (mode !== 'shell' && known === null) {
|
|
742
742
|
return { code: 4004, status: 404, error: 'unknown session' };
|
|
743
743
|
}
|
|
@@ -783,7 +783,7 @@ function createTerminalService(o) {
|
|
|
783
783
|
function list() {
|
|
784
784
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
785
785
|
id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
786
|
-
name: s.name, worktree: s.worktree, edit: s.edit,
|
|
786
|
+
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
|
|
787
787
|
}));
|
|
788
788
|
}
|
|
789
789
|
|
|
@@ -827,12 +827,11 @@ function createTerminalService(o) {
|
|
|
827
827
|
}
|
|
828
828
|
|
|
829
829
|
// Pastes into claude's input box and stops there: Enter would also submit whatever the
|
|
830
|
-
// user had half-typed, and over a permission dialog it would answer it. A
|
|
831
|
-
//
|
|
832
|
-
// pending queued prompt owns the input box until it is sent.
|
|
830
|
+
// user had half-typed, and over a permission dialog it would answer it. A pending queued
|
|
831
|
+
// prompt owns the input box until it is sent.
|
|
833
832
|
function paste(id, text) {
|
|
834
833
|
const s = sessions.get(id);
|
|
835
|
-
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell'
|
|
834
|
+
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell') return false;
|
|
836
835
|
if (onTrustScreen(s)) return false;
|
|
837
836
|
// Until the user runs the typed command, the input box is the shell's command line.
|
|
838
837
|
if (s.edit && !s.claudePid) return false;
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -53,6 +53,7 @@ const collapsedProjectGroups = new Set();
|
|
|
53
53
|
const SECTION_GROUPS = '__section_groups__';
|
|
54
54
|
const SECTION_PROJECTS = '__section_projects__';
|
|
55
55
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
56
|
+
const SECTION_OUTSIDE = '__section_outside__';
|
|
56
57
|
// Project paths and session ids in Active-view sidebar order, kept across reloads so live
|
|
57
58
|
// activity does not reshuffle the list. Each holds only what the loaded list still has.
|
|
58
59
|
const PROJECT_ORDER_KEY = 'project-order';
|
|
@@ -373,6 +374,65 @@ async function fetchSessionById(id) {
|
|
|
373
374
|
return (await fetchSessionsByIds([id]))[0] || null;
|
|
374
375
|
}
|
|
375
376
|
|
|
377
|
+
// The id may be outside the list the filters asked for. renderSession bails on a session
|
|
378
|
+
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
379
|
+
// keep it, because fetchSessions pins currentSessionId.
|
|
380
|
+
async function ensureSessionLoaded(id) {
|
|
381
|
+
const loaded = sessions.find((s) => s.id === id);
|
|
382
|
+
if (loaded) return loaded;
|
|
383
|
+
const session = await fetchSessionById(id);
|
|
384
|
+
if (!session) return null;
|
|
385
|
+
const raced = sessions.find((s) => s.id === id);
|
|
386
|
+
if (raced) return raced;
|
|
387
|
+
sessions.push(session);
|
|
388
|
+
return session;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Matches SESSION_SEARCH_MIN in server.js, which answers [] below it.
|
|
392
|
+
const SESSION_SEARCH_MIN = 3;
|
|
393
|
+
const SESSION_SEARCH_DEBOUNCE_MS = 100;
|
|
394
|
+
|
|
395
|
+
// Searches session ids and names in every transcript on the server, so a session outside the
|
|
396
|
+
// sidebar filter is found too. `rows` answers the server rows not in `localRows`, and whether
|
|
397
|
+
// the search for that query is still out; `onFound()` runs when the current query's rows arrive.
|
|
398
|
+
// Only the current query is kept, and a short query drops it, so a later identical query fetches again.
|
|
399
|
+
function sessionSearch(onFound) {
|
|
400
|
+
let current = null;
|
|
401
|
+
let found = null;
|
|
402
|
+
let timer = null;
|
|
403
|
+
const run = async (key) => {
|
|
404
|
+
let ids = [];
|
|
405
|
+
try {
|
|
406
|
+
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
407
|
+
if (r.ok) ids = await r.json();
|
|
408
|
+
} catch (_) {}
|
|
409
|
+
if (key !== current) return;
|
|
410
|
+
const rows = ids.length ? await fetchSessionsByIds(ids) : [];
|
|
411
|
+
if (key !== current) return;
|
|
412
|
+
found = rows;
|
|
413
|
+
onFound();
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
rows(query, localRows) {
|
|
417
|
+
const key = query.length < SESSION_SEARCH_MIN ? null : query.toLowerCase();
|
|
418
|
+
// Set once per query: the sidebar re-renders on every live update, and resetting the
|
|
419
|
+
// timer each time would starve the search.
|
|
420
|
+
if (key !== current) {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
current = key;
|
|
423
|
+
found = null;
|
|
424
|
+
if (key) timer = setTimeout(() => run(key), SESSION_SEARCH_DEBOUNCE_MS);
|
|
425
|
+
}
|
|
426
|
+
if (!key) return { rows: [], pending: false };
|
|
427
|
+
if (!found) return { rows: [], pending: true };
|
|
428
|
+
const seen = new Set(localRows.map((s) => s.id));
|
|
429
|
+
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const sidebarSearch = sessionSearch(renderSessions);
|
|
435
|
+
|
|
376
436
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
377
437
|
function handleSearch(query) {
|
|
378
438
|
const wasSearching = !!searchQuery;
|
|
@@ -3406,15 +3466,8 @@ async function revealSession(id) {
|
|
|
3406
3466
|
let session = sessions.find((s) => s.id === id);
|
|
3407
3467
|
if (!session) {
|
|
3408
3468
|
lastSessionsHash = '';
|
|
3409
|
-
await fetchSessions();
|
|
3410
|
-
session =
|
|
3411
|
-
}
|
|
3412
|
-
if (!session) {
|
|
3413
|
-
// The id is outside the list the filters asked for. renderSession bails on a session
|
|
3414
|
-
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
3415
|
-
// keep it, because fetchSessions pins currentSessionId.
|
|
3416
|
-
session = await fetchSessionById(id);
|
|
3417
|
-
if (session) sessions.push(session);
|
|
3469
|
+
await fetchSessions(true, id);
|
|
3470
|
+
session = await ensureSessionLoaded(id);
|
|
3418
3471
|
}
|
|
3419
3472
|
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3420
3473
|
if (uncollapsed) persistCollapsedGroups();
|
|
@@ -3603,36 +3656,8 @@ function renderSessions() {
|
|
|
3603
3656
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3604
3657
|
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
|
|
3605
3658
|
|
|
3606
|
-
|
|
3607
|
-
let emptyMsg = 'No sessions found';
|
|
3608
|
-
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3659
|
+
const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
|
|
3609
3660
|
|
|
3610
|
-
if (zenMode) {
|
|
3611
|
-
emptyMsg = 'Zen mode: no session open';
|
|
3612
|
-
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3613
|
-
} else if (searchQuery) {
|
|
3614
|
-
emptyMsg = `No results for "${searchQuery}"`;
|
|
3615
|
-
emptyHint = 'Try a different search term or clear the search';
|
|
3616
|
-
} else if (filterProject && sessionFilter === 'active') {
|
|
3617
|
-
emptyMsg = 'No active sessions for this project filter';
|
|
3618
|
-
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3619
|
-
} else if (filterProject) {
|
|
3620
|
-
emptyMsg = 'No sessions for this project filter';
|
|
3621
|
-
emptyHint = 'Clear the project filter to see all';
|
|
3622
|
-
} else if (sessionFilter === 'active') {
|
|
3623
|
-
emptyMsg = 'No active sessions';
|
|
3624
|
-
emptyHint = 'Select "All Sessions" to see all';
|
|
3625
|
-
}
|
|
3626
|
-
sessionsList.innerHTML = `
|
|
3627
|
-
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
3628
|
-
<p>${emptyMsg}</p>
|
|
3629
|
-
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
3630
|
-
</div>
|
|
3631
|
-
`;
|
|
3632
|
-
return;
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
|
-
// Helper to render a single session card
|
|
3636
3661
|
const renderSessionCard = (session) => {
|
|
3637
3662
|
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3638
3663
|
const total = session.taskCount;
|
|
@@ -3703,6 +3728,63 @@ function renderSessions() {
|
|
|
3703
3728
|
`;
|
|
3704
3729
|
};
|
|
3705
3730
|
|
|
3731
|
+
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3732
|
+
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3733
|
+
const collapsed = collapsedProjectGroups.has(key);
|
|
3734
|
+
const escPath = escapeHtml(key);
|
|
3735
|
+
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3736
|
+
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3737
|
+
return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
|
|
3738
|
+
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3739
|
+
};
|
|
3740
|
+
|
|
3741
|
+
const countHtml = (arr) => {
|
|
3742
|
+
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3743
|
+
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3744
|
+
};
|
|
3745
|
+
|
|
3746
|
+
const outsideHtml = outside.rows.length
|
|
3747
|
+
? sectionHtml(
|
|
3748
|
+
SECTION_OUTSIDE,
|
|
3749
|
+
`Outside current filters ${countHtml(outside.rows)}`,
|
|
3750
|
+
false,
|
|
3751
|
+
collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
|
|
3752
|
+
)
|
|
3753
|
+
: '';
|
|
3754
|
+
|
|
3755
|
+
if (filteredSessions.length === 0) {
|
|
3756
|
+
let emptyMsg = 'No sessions found';
|
|
3757
|
+
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3758
|
+
|
|
3759
|
+
if (zenMode) {
|
|
3760
|
+
emptyMsg = 'Zen mode: no session open';
|
|
3761
|
+
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3762
|
+
} else if (searchQuery) {
|
|
3763
|
+
emptyMsg = `No results for "${escapeHtml(searchQuery)}"`;
|
|
3764
|
+
emptyHint = outside.rows.length
|
|
3765
|
+
? 'Matches outside the current filters are below'
|
|
3766
|
+
: outside.pending
|
|
3767
|
+
? 'Searching all sessions…'
|
|
3768
|
+
: 'Try a different search term or clear the search';
|
|
3769
|
+
} else if (filterProject && sessionFilter === 'active') {
|
|
3770
|
+
emptyMsg = 'No active sessions for this project filter';
|
|
3771
|
+
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3772
|
+
} else if (filterProject) {
|
|
3773
|
+
emptyMsg = 'No sessions for this project filter';
|
|
3774
|
+
emptyHint = 'Clear the project filter to see all';
|
|
3775
|
+
} else if (sessionFilter === 'active') {
|
|
3776
|
+
emptyMsg = 'No active sessions';
|
|
3777
|
+
emptyHint = 'Select "All Sessions" to see all';
|
|
3778
|
+
}
|
|
3779
|
+
sessionsList.innerHTML = `
|
|
3780
|
+
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
3781
|
+
<p>${emptyMsg}</p>
|
|
3782
|
+
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
3783
|
+
</div>
|
|
3784
|
+
${outsideHtml}`;
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3706
3788
|
// Zen draws one card and its panel, so it returns before the grouping below — leaving it in
|
|
3707
3789
|
// that chain would build the whole list only to discard it.
|
|
3708
3790
|
if (zenMode) {
|
|
@@ -3725,20 +3807,6 @@ function renderSessions() {
|
|
|
3725
3807
|
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3726
3808
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3727
3809
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3728
|
-
const countHtml = (arr) => {
|
|
3729
|
-
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3730
|
-
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3731
|
-
};
|
|
3732
|
-
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3733
|
-
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3734
|
-
const collapsed = collapsedProjectGroups.has(key);
|
|
3735
|
-
const escPath = escapeHtml(key);
|
|
3736
|
-
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3737
|
-
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3738
|
-
return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
|
|
3739
|
-
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3740
|
-
};
|
|
3741
|
-
|
|
3742
3810
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
3743
3811
|
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3744
3812
|
const gIdlePinned = sessions.filter(isInPinnedGroup);
|
|
@@ -3958,13 +4026,13 @@ function renderSessions() {
|
|
|
3958
4026
|
const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
|
|
3959
4027
|
html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
|
|
3960
4028
|
|
|
3961
|
-
sessionsList.innerHTML = html;
|
|
4029
|
+
sessionsList.innerHTML = html + outsideHtml;
|
|
3962
4030
|
} else {
|
|
3963
4031
|
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3964
4032
|
const restHtml =
|
|
3965
4033
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3966
4034
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
3967
|
-
sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
|
|
4035
|
+
sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
|
|
3968
4036
|
}
|
|
3969
4037
|
|
|
3970
4038
|
const navItems = getNavigableItems();
|
|
@@ -5022,10 +5090,12 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
5022
5090
|
menu.id = 'sg-menu';
|
|
5023
5091
|
menu.className = 'sg-menu';
|
|
5024
5092
|
menu.setAttribute('role', 'menu');
|
|
5025
|
-
|
|
5093
|
+
const canFork = kind === 'session' && !!forkableSession(ref);
|
|
5094
|
+
menu.setAttribute('aria-label', canFork ? 'Session actions' : `Move ${label} to group`);
|
|
5026
5095
|
menu.dataset.sgKind = kind;
|
|
5027
5096
|
menu.dataset.sgRef = ref;
|
|
5028
5097
|
menu.innerHTML = `
|
|
5098
|
+
${canFork ? '<div class="sg-menu-label" aria-hidden="true">Session</div><button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>' : ''}
|
|
5029
5099
|
<div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
|
|
5030
5100
|
${rows}
|
|
5031
5101
|
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
@@ -5073,6 +5143,7 @@ document.addEventListener('click', (e) => {
|
|
|
5073
5143
|
const { sgKind, sgRef } = menu.dataset;
|
|
5074
5144
|
const move = item.dataset.sgMove;
|
|
5075
5145
|
sgCloseMenu();
|
|
5146
|
+
if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
|
|
5076
5147
|
if (move === '__none__') {
|
|
5077
5148
|
sgDetach(sgKind, sgRef);
|
|
5078
5149
|
persistSessionGroups();
|
|
@@ -5316,18 +5387,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5316
5387
|
}
|
|
5317
5388
|
|
|
5318
5389
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
5319
|
-
// the project or the session itself, the pinned sub-section only when the card renders inside
|
|
5320
|
-
// it (pinned, not sticky, idle — see renderGroupSessions), and
|
|
5321
|
-
//
|
|
5390
|
+
// the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
|
|
5391
|
+
// it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
|
|
5392
|
+
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5393
|
+
// whether anything changed.
|
|
5322
5394
|
function uncollapseFor(session) {
|
|
5323
5395
|
const group = sgGroupForSession(session);
|
|
5324
5396
|
const keys = [
|
|
5325
5397
|
sessionProjectKey(session) || '__ungrouped__',
|
|
5326
5398
|
...pinnedCollapseKeys(session),
|
|
5327
|
-
group
|
|
5328
|
-
SECTION_GROUPS,
|
|
5329
|
-
SECTION_PROJECTS,
|
|
5330
|
-
SECTION_SESSIONS,
|
|
5399
|
+
...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
|
|
5331
5400
|
];
|
|
5332
5401
|
let changed = false;
|
|
5333
5402
|
for (const key of keys) {
|
|
@@ -11058,38 +11127,9 @@ let spRenderedQuery = null;
|
|
|
11058
11127
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
11059
11128
|
const spPeekCache = new Map();
|
|
11060
11129
|
|
|
11061
|
-
|
|
11062
|
-
|
|
11063
|
-
|
|
11064
|
-
let spSearchTimer = null;
|
|
11065
|
-
|
|
11066
|
-
function spSearchKey(query) {
|
|
11067
|
-
return query.length >= 3 ? query.toLowerCase() : null;
|
|
11068
|
-
}
|
|
11069
|
-
|
|
11070
|
-
function spScheduleSearch(key) {
|
|
11071
|
-
clearTimeout(spSearchTimer);
|
|
11072
|
-
spSearchTimer = setTimeout(async () => {
|
|
11073
|
-
spSearch.set(key, 'pending');
|
|
11074
|
-
let ids = [];
|
|
11075
|
-
try {
|
|
11076
|
-
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
11077
|
-
if (r.ok) ids = await r.json();
|
|
11078
|
-
} catch (_) {}
|
|
11079
|
-
spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
|
|
11080
|
-
renderSessionPicker();
|
|
11081
|
-
}, 100);
|
|
11082
|
-
}
|
|
11083
|
-
|
|
11084
|
-
// The server rows not already matched in the sidebar list, and whether a search is still out.
|
|
11085
|
-
function spSearchRows(key, localRows) {
|
|
11086
|
-
if (!key) return { rows: [], pending: false };
|
|
11087
|
-
const found = spSearch.get(key);
|
|
11088
|
-
if (!found) spScheduleSearch(key);
|
|
11089
|
-
if (!Array.isArray(found)) return { rows: [], pending: true };
|
|
11090
|
-
const seen = new Set(localRows.map((s) => s.id));
|
|
11091
|
-
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
11092
|
-
}
|
|
11130
|
+
const spSearch = sessionSearch(() => {
|
|
11131
|
+
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
11132
|
+
});
|
|
11093
11133
|
|
|
11094
11134
|
function openSessionPicker() {
|
|
11095
11135
|
spFromTerminal = terminalPaneFocused();
|
|
@@ -11097,7 +11137,6 @@ function openSessionPicker() {
|
|
|
11097
11137
|
input.value = '';
|
|
11098
11138
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
11099
11139
|
spPeekCache.clear();
|
|
11100
|
-
spSearch.clear();
|
|
11101
11140
|
spRenderedQuery = null;
|
|
11102
11141
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
11103
11142
|
renderSessionPicker();
|
|
@@ -11220,15 +11259,14 @@ function renderSessionPicker() {
|
|
|
11220
11259
|
// Search rows that arrive for the same query append below, so the cursor stays on its row.
|
|
11221
11260
|
const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
11222
11261
|
spRenderedQuery = query;
|
|
11223
|
-
const key = spSearchKey(query);
|
|
11224
11262
|
const local = spSource.filter((s) => spMatches(s, query));
|
|
11225
|
-
const found =
|
|
11263
|
+
const found = spSearch.rows(query, local);
|
|
11226
11264
|
spRows = [...local, ...found.rows];
|
|
11227
11265
|
if (!spRows.length) {
|
|
11228
11266
|
spIdx = -1;
|
|
11229
11267
|
const empty = found.pending
|
|
11230
11268
|
? 'Searching all sessions…'
|
|
11231
|
-
: spSource.length ||
|
|
11269
|
+
: spSource.length || query.length >= SESSION_SEARCH_MIN
|
|
11232
11270
|
? 'No session matches'
|
|
11233
11271
|
: 'No sessions in the current sidebar filter';
|
|
11234
11272
|
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
@@ -13420,8 +13458,10 @@ function syncTerminal() {
|
|
|
13420
13458
|
}
|
|
13421
13459
|
|
|
13422
13460
|
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
13423
|
-
function openSession(sessionId) {
|
|
13461
|
+
async function openSession(sessionId) {
|
|
13424
13462
|
setSessionDismissed(sessionId, false);
|
|
13463
|
+
// Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
|
|
13464
|
+
if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
|
|
13425
13465
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
13426
13466
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
13427
13467
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -13921,6 +13961,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
13921
13961
|
model: spec.model,
|
|
13922
13962
|
prompt: spec.prompt,
|
|
13923
13963
|
edit: spec.edit,
|
|
13964
|
+
forkOf: spec.forkOf,
|
|
13924
13965
|
}),
|
|
13925
13966
|
},
|
|
13926
13967
|
});
|
|
@@ -14012,7 +14053,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
14012
14053
|
'Resuming it here too makes both processes write to the same transcript.',
|
|
14013
14054
|
[
|
|
14014
14055
|
['resume', 'Resume anyway'],
|
|
14015
|
-
['fork', 'Fork'],
|
|
14016
14056
|
['shell', 'Shell only'],
|
|
14017
14057
|
],
|
|
14018
14058
|
);
|
|
@@ -14037,7 +14077,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
14037
14077
|
]
|
|
14038
14078
|
: [
|
|
14039
14079
|
['resume', 'Resume'],
|
|
14040
|
-
['fork', 'Fork'],
|
|
14041
14080
|
['shell', 'Shell'],
|
|
14042
14081
|
],
|
|
14043
14082
|
clean ? '' : tail,
|
|
@@ -14220,13 +14259,18 @@ const PLACEHOLDER_HINTS = {
|
|
|
14220
14259
|
edit: 'press Enter in the terminal to start',
|
|
14221
14260
|
};
|
|
14222
14261
|
|
|
14262
|
+
// The parent may be outside the loaded list, so its short id stands in for its name.
|
|
14263
|
+
function forkLabel(parentId) {
|
|
14264
|
+
return `Fork of ${sessionDisplayName(sessions.find((s) => s.id === parentId) || { id: parentId })}`;
|
|
14265
|
+
}
|
|
14266
|
+
|
|
14223
14267
|
function placeholderSession(id, spec) {
|
|
14224
14268
|
return {
|
|
14225
14269
|
id,
|
|
14226
14270
|
placeholder: true,
|
|
14227
14271
|
mode: spec.mode,
|
|
14228
14272
|
edit: !!spec.edit,
|
|
14229
|
-
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
14273
|
+
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
14230
14274
|
dispatchGroup: spec.group || undefined,
|
|
14231
14275
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
14232
14276
|
project: spec.cwd,
|
|
@@ -14325,6 +14369,7 @@ async function restorePendingSessions() {
|
|
|
14325
14369
|
worktree: t.worktree,
|
|
14326
14370
|
mode: t.mode,
|
|
14327
14371
|
edit: t.edit,
|
|
14372
|
+
forkOf: t.forkOf,
|
|
14328
14373
|
startedAt: t.startedAt,
|
|
14329
14374
|
});
|
|
14330
14375
|
}
|
|
@@ -14553,11 +14598,29 @@ function startNewSession() {
|
|
|
14553
14598
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
14554
14599
|
setTerminalMode(id, true);
|
|
14555
14600
|
if (v.stay) return startNewSessionInBackground(id, v);
|
|
14556
|
-
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14557
14601
|
closeNewSession();
|
|
14602
|
+
openPlaceholder(id);
|
|
14603
|
+
}
|
|
14604
|
+
|
|
14605
|
+
function openPlaceholder(id) {
|
|
14606
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14558
14607
|
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
14559
14608
|
}
|
|
14560
14609
|
|
|
14610
|
+
function forkableSession(id) {
|
|
14611
|
+
return terminalAvailable() ? sessions.find((s) => s.id === id && !s.placeholder) : undefined;
|
|
14612
|
+
}
|
|
14613
|
+
|
|
14614
|
+
// The fork gets its own id up front, so it runs as a new session and needs no rekey.
|
|
14615
|
+
function forkSession(parentId) {
|
|
14616
|
+
const parent = forkableSession(parentId);
|
|
14617
|
+
if (!parent) return;
|
|
14618
|
+
const id = crypto.randomUUID();
|
|
14619
|
+
newSpecs.set(id, { cwd: parent.project, forkOf: parentId, mode: 'new', startedAt: Date.now() });
|
|
14620
|
+
setTerminalMode(id, true);
|
|
14621
|
+
openPlaceholder(id);
|
|
14622
|
+
}
|
|
14623
|
+
|
|
14561
14624
|
async function startNewSessionInBackground(id, v) {
|
|
14562
14625
|
refreshPlaceholders();
|
|
14563
14626
|
setNewSessionBusy(true);
|
package/public/index.html
CHANGED
|
@@ -138,6 +138,7 @@
|
|
|
138
138
|
class="pf-input"
|
|
139
139
|
placeholder="Any project"
|
|
140
140
|
autocomplete="off"
|
|
141
|
+
data-bwignore data-1p-ignore data-lpignore="true"
|
|
141
142
|
spellcheck="false"
|
|
142
143
|
role="combobox"
|
|
143
144
|
aria-expanded="true"
|
|
@@ -169,6 +170,8 @@
|
|
|
169
170
|
type="text"
|
|
170
171
|
class="search-input"
|
|
171
172
|
placeholder="Search tasks, sessions, projects..."
|
|
173
|
+
autocomplete="off"
|
|
174
|
+
data-bwignore data-1p-ignore data-lpignore="true"
|
|
172
175
|
oninput="handleSearch(this.value)"
|
|
173
176
|
/>
|
|
174
177
|
<button id="search-clear-btn" class="search-clear" onclick="clearSearch()" title="Clear search" aria-label="Clear search">
|
|
@@ -503,7 +506,7 @@
|
|
|
503
506
|
</div>
|
|
504
507
|
<div class="help-bar">
|
|
505
508
|
<div class="help-tabs" id="help-tabs" role="tablist" aria-label="Shortcut groups"></div>
|
|
506
|
-
<input type="search" id="help-filter" class="form-input help-filter" placeholder="Filter shortcuts" aria-label="Filter shortcuts" autocomplete="off" spellcheck="false">
|
|
509
|
+
<input type="search" id="help-filter" class="form-input help-filter" placeholder="Filter shortcuts" aria-label="Filter shortcuts" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
507
510
|
</div>
|
|
508
511
|
<div class="modal-body">
|
|
509
512
|
<dl class="shortcuts" id="help-shortcuts"></dl>
|
|
@@ -802,7 +805,7 @@
|
|
|
802
805
|
<label class="ns-label" for="ns-folder">Folder</label>
|
|
803
806
|
<div class="ns-field ns-folder-field">
|
|
804
807
|
<div class="ns-inline">
|
|
805
|
-
<input type="text" id="ns-folder" class="form-input" placeholder="Type to find a project" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="ns-folder-list">
|
|
808
|
+
<input type="text" id="ns-folder" class="form-input" placeholder="Type to find a project" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="ns-folder-list">
|
|
806
809
|
<button type="button" id="ns-browse" class="btn btn-secondary ns-browse" onclick="browseNewSessionFolder()">Browse…</button>
|
|
807
810
|
</div>
|
|
808
811
|
<div id="ns-folder-list" class="ns-dropdown" role="listbox" hidden></div>
|
|
@@ -811,7 +814,7 @@
|
|
|
811
814
|
<div class="ns-row ns-extra">
|
|
812
815
|
<label class="ns-label" for="ns-name">Name</label>
|
|
813
816
|
<div class="ns-field">
|
|
814
|
-
<input type="text" id="ns-name" class="form-input" placeholder="Optional" maxlength="80" autocomplete="off" spellcheck="false">
|
|
817
|
+
<input type="text" id="ns-name" class="form-input" placeholder="Optional" maxlength="80" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
815
818
|
</div>
|
|
816
819
|
</div>
|
|
817
820
|
<div class="ns-row ns-extra">
|
|
@@ -828,7 +831,7 @@
|
|
|
828
831
|
<div class="ns-field">
|
|
829
832
|
<div class="ns-inline">
|
|
830
833
|
<label class="ns-check"><input type="checkbox" id="ns-wt"> New worktree</label>
|
|
831
|
-
<input type="text" id="ns-wt-name" class="form-input" placeholder="Name (optional)" maxlength="64" autocomplete="off" spellcheck="false" disabled aria-label="Worktree name">
|
|
834
|
+
<input type="text" id="ns-wt-name" class="form-input" placeholder="Name (optional)" maxlength="64" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false" disabled aria-label="Worktree name">
|
|
832
835
|
</div>
|
|
833
836
|
<div id="ns-wt-hint" class="ns-hint" hidden></div>
|
|
834
837
|
</div>
|
|
@@ -864,7 +867,7 @@
|
|
|
864
867
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
865
868
|
</button>
|
|
866
869
|
</div>
|
|
867
|
-
<input id="pane-pop-input" class="form-input" placeholder="Search linked docs, or paste a URL or a file path" autocomplete="off" spellcheck="false">
|
|
870
|
+
<input id="pane-pop-input" class="form-input" placeholder="Search linked docs, or paste a URL or a file path" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
868
871
|
<div id="pane-pop-detect" class="pane-pop-detect"></div>
|
|
869
872
|
<div class="pane-pop-label">Linked docs</div>
|
|
870
873
|
<div id="pane-pop-docs" class="pane-pop-docs"></div>
|
|
@@ -874,7 +877,7 @@
|
|
|
874
877
|
|
|
875
878
|
<div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
|
|
876
879
|
<div class="modal session-picker" onclick="event.stopPropagation()">
|
|
877
|
-
<input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" spellcheck="false">
|
|
880
|
+
<input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
878
881
|
<div class="sp-body">
|
|
879
882
|
<div id="session-picker-list" class="sp-list"></div>
|
|
880
883
|
<div id="session-picker-peek" class="sp-peek"></div>
|
|
@@ -885,7 +888,7 @@
|
|
|
885
888
|
|
|
886
889
|
<div id="project-picker-modal" class="modal-overlay top-aligned" onclick="closeProjectPicker()">
|
|
887
890
|
<div class="modal session-picker" onclick="event.stopPropagation()">
|
|
888
|
-
<input type="text" id="project-picker-input" class="form-input sp-input" placeholder="Filter by project..." autocomplete="off" spellcheck="false">
|
|
891
|
+
<input type="text" id="project-picker-input" class="form-input sp-input" placeholder="Filter by project..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
889
892
|
<div class="pp-chips">
|
|
890
893
|
<button type="button" class="pf-recent" id="pp-recent-chip" aria-pressed="false" title="List only projects active in the last 24h" onclick="ppToggleRecentOnly()">Recent</button>
|
|
891
894
|
</div>
|
package/public/style.css
CHANGED
|
@@ -7143,7 +7143,8 @@ select.form-input option:checked {
|
|
|
7143
7143
|
|
|
7144
7144
|
/* #region SESSION_PICKER */
|
|
7145
7145
|
.modal.session-picker {
|
|
7146
|
-
max-
|
|
7146
|
+
max-width: 920px;
|
|
7147
|
+
max-height: 74vh;
|
|
7147
7148
|
padding: 12px;
|
|
7148
7149
|
display: flex;
|
|
7149
7150
|
flex-direction: column;
|
|
@@ -7152,9 +7153,9 @@ select.form-input option:checked {
|
|
|
7152
7153
|
}
|
|
7153
7154
|
|
|
7154
7155
|
.sp-input {
|
|
7155
|
-
padding:
|
|
7156
|
+
padding: 10px 12px;
|
|
7156
7157
|
background: var(--bg-deep);
|
|
7157
|
-
font-size:
|
|
7158
|
+
font-size: 14px;
|
|
7158
7159
|
}
|
|
7159
7160
|
|
|
7160
7161
|
.modal.session-picker.peeking {
|
|
@@ -7197,7 +7198,7 @@ select.form-input option:checked {
|
|
|
7197
7198
|
gap: 8px;
|
|
7198
7199
|
margin-bottom: 6px;
|
|
7199
7200
|
font-family: var(--mono);
|
|
7200
|
-
font-size:
|
|
7201
|
+
font-size: 11.5px;
|
|
7201
7202
|
color: var(--text-muted);
|
|
7202
7203
|
white-space: nowrap;
|
|
7203
7204
|
overflow: hidden;
|
|
@@ -7215,13 +7216,13 @@ select.form-input option:checked {
|
|
|
7215
7216
|
align-items: center;
|
|
7216
7217
|
gap: 8px;
|
|
7217
7218
|
width: 100%;
|
|
7218
|
-
padding:
|
|
7219
|
+
padding: 7px 8px;
|
|
7219
7220
|
background: none;
|
|
7220
7221
|
border: 1px solid transparent;
|
|
7221
7222
|
border-radius: 5px;
|
|
7222
7223
|
color: var(--text-secondary);
|
|
7223
7224
|
font-family: var(--mono);
|
|
7224
|
-
font-size:
|
|
7225
|
+
font-size: 13px;
|
|
7225
7226
|
text-align: left;
|
|
7226
7227
|
cursor: pointer;
|
|
7227
7228
|
}
|
|
@@ -7330,14 +7331,14 @@ select.form-input option:checked {
|
|
|
7330
7331
|
border-radius: 3px;
|
|
7331
7332
|
background: var(--bg-hover);
|
|
7332
7333
|
color: var(--text-tertiary);
|
|
7333
|
-
font-size:
|
|
7334
|
+
font-size: 11.5px;
|
|
7334
7335
|
}
|
|
7335
7336
|
|
|
7336
7337
|
.sp-count,
|
|
7337
7338
|
.sp-time {
|
|
7338
7339
|
flex: none;
|
|
7339
7340
|
color: var(--text-muted);
|
|
7340
|
-
font-size:
|
|
7341
|
+
font-size: 11.5px;
|
|
7341
7342
|
white-space: nowrap;
|
|
7342
7343
|
}
|
|
7343
7344
|
|