@bakapiano/ccsm 0.6.0 → 0.8.4
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/CLAUDE.md +377 -123
- package/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/favorites.js +23 -45
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +21 -41
- package/lib/webTerminal.js +173 -0
- package/package.json +11 -3
- package/public/css/base.css +82 -0
- package/public/css/cards.css +149 -0
- package/public/css/feedback.css +219 -0
- package/public/css/forms.css +282 -0
- package/public/css/layout.css +107 -0
- package/public/css/modal.css +169 -0
- package/public/css/responsive.css +10 -0
- package/public/css/sidebar.css +165 -0
- package/public/css/tables.css +266 -0
- package/public/css/terminals.css +112 -0
- package/public/css/tokens.css +63 -0
- package/public/css/wco.css +70 -0
- package/public/css/widgets.css +204 -0
- package/public/favicon.svg +1 -1
- package/public/index.html +52 -490
- package/public/js/actions.js +87 -0
- package/public/js/api.js +103 -0
- package/public/js/backend.js +28 -0
- package/public/js/components/App.js +45 -0
- package/public/js/components/Card.js +24 -0
- package/public/js/components/DialogHost.js +45 -0
- package/public/js/components/Fab.js +11 -0
- package/public/js/components/FavoritesTable.js +81 -0
- package/public/js/components/Footer.js +12 -0
- package/public/js/components/NewSessionModal.js +142 -0
- package/public/js/components/OfflineBanner.js +52 -0
- package/public/js/components/PageHead.js +33 -0
- package/public/js/components/Pagination.js +27 -0
- package/public/js/components/ProgressList.js +32 -0
- package/public/js/components/RecentTable.js +68 -0
- package/public/js/components/RepoPicker.js +40 -0
- package/public/js/components/ReposEditor.js +74 -0
- package/public/js/components/ServerStatus.js +18 -0
- package/public/js/components/SessionsTable.js +71 -0
- package/public/js/components/Sidebar.js +52 -0
- package/public/js/components/SnapshotPanel.js +77 -0
- package/public/js/components/TerminalView.js +108 -0
- package/public/js/components/TitleCell.js +40 -0
- package/public/js/components/Toast.js +8 -0
- package/public/js/components/WorkspacePicker.js +19 -0
- package/public/js/components/WorkspacesGrid.js +41 -0
- package/public/js/dialog.js +59 -0
- package/public/js/html.js +6 -0
- package/public/js/icons.js +114 -0
- package/public/js/main.js +81 -0
- package/public/js/pages/AboutPage.js +85 -0
- package/public/js/pages/ConfigurePage.js +194 -0
- package/public/js/pages/LaunchPage.js +117 -0
- package/public/js/pages/SessionsPage.js +47 -0
- package/public/js/pages/TerminalsPage.js +74 -0
- package/public/js/state.js +87 -0
- package/public/js/streaming.js +96 -0
- package/public/js/toast.js +14 -0
- package/public/js/util.js +24 -0
- package/public/manifest.webmanifest +14 -0
- package/scripts/install.js +132 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +286 -30
- package/public/app.js +0 -1353
- package/public/styles.css +0 -1639
package/public/js/api.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Fetch wrapper + every loader. Loaders push into signals from ./state.js.
|
|
2
|
+
// Cross-origin (hosted frontend → local backend) flows through httpBase().
|
|
3
|
+
|
|
4
|
+
import * as S from './state.js';
|
|
5
|
+
import { httpBase } from './backend.js';
|
|
6
|
+
|
|
7
|
+
export async function api(method, url, body) {
|
|
8
|
+
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
9
|
+
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
10
|
+
const r = await fetch(httpBase() + url, opts);
|
|
11
|
+
const text = await r.text();
|
|
12
|
+
let json;
|
|
13
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
14
|
+
if (!r.ok) throw new Error(json.error || `HTTP ${r.status}`);
|
|
15
|
+
return json;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadConfig() {
|
|
19
|
+
const [cfg, terms, caps] = await Promise.all([
|
|
20
|
+
api('GET', '/api/config'),
|
|
21
|
+
api('GET', '/api/terminals'),
|
|
22
|
+
api('GET', '/api/capabilities').catch(() => ({ webTerminal: false })),
|
|
23
|
+
]);
|
|
24
|
+
S.config.value = cfg;
|
|
25
|
+
S.terminals.value = terms.terminals;
|
|
26
|
+
S.capabilities.value = caps;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function loadWebTerminals() {
|
|
30
|
+
try {
|
|
31
|
+
const r = await api('GET', '/api/sessions/web');
|
|
32
|
+
S.webTerminals.value = r.terminals || [];
|
|
33
|
+
} catch { /* node-pty might be unavailable */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function killWebTerminal(id) {
|
|
37
|
+
await api('DELETE', `/api/sessions/web/${id}`);
|
|
38
|
+
await loadWebTerminals();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadSessions() {
|
|
42
|
+
const r = await api('GET', '/api/sessions');
|
|
43
|
+
S.sessions.value = r.sessions;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function loadRecent() {
|
|
47
|
+
const r = await api('GET', `/api/sessions/recent?limit=${S.recentLimit.value}&offset=${S.recentOffset.value}`);
|
|
48
|
+
S.recent.value = r.recent;
|
|
49
|
+
S.recentTotal.value = r.total || 0;
|
|
50
|
+
S.recentLimit.value = r.limit || S.recentLimit.value;
|
|
51
|
+
S.recentOffset.value = r.offset || 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loadFavorites() {
|
|
55
|
+
try {
|
|
56
|
+
const r = await api('GET', '/api/favorites');
|
|
57
|
+
const map = {};
|
|
58
|
+
for (const f of r.favorites || []) map[f.sessionId] = f;
|
|
59
|
+
S.favorites.value = map;
|
|
60
|
+
} catch (e) { /* ignore — endpoint may not exist on older servers */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadLabels() {
|
|
64
|
+
try {
|
|
65
|
+
const r = await api('GET', '/api/labels');
|
|
66
|
+
S.labels.value = r.labels || {};
|
|
67
|
+
} catch (e) { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadSnapshot() {
|
|
71
|
+
const r = await api('GET', '/api/snapshot');
|
|
72
|
+
S.snapshot.value = r.snapshot;
|
|
73
|
+
const h = await api('GET', '/api/snapshot/history');
|
|
74
|
+
S.history.value = h.history;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function loadWorkspaces() {
|
|
78
|
+
const r = await api('GET', '/api/workspaces');
|
|
79
|
+
S.workspaces.value = r.workspaces;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function refreshAll() {
|
|
83
|
+
await Promise.all([
|
|
84
|
+
loadSessions(), loadRecent(), loadSnapshot(),
|
|
85
|
+
loadWorkspaces(), loadFavorites(), loadLabels(), loadWebTerminals(),
|
|
86
|
+
]);
|
|
87
|
+
S.lastRefreshAt.value = Date.now();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function pollHealth() {
|
|
91
|
+
const ctrl = new AbortController();
|
|
92
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
93
|
+
try {
|
|
94
|
+
const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
|
|
95
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
96
|
+
const j = await r.json();
|
|
97
|
+
S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid };
|
|
98
|
+
} catch (e) {
|
|
99
|
+
S.serverHealth.value = { state: 'offline', error: String(e.message || e) };
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(t);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// One source of truth for "where is the ccsm backend reachable".
|
|
2
|
+
//
|
|
3
|
+
// localhost / 127.0.0.1 → same-origin (page IS the backend)
|
|
4
|
+
// everything else → http://localhost:7777 (hosted frontend
|
|
5
|
+
// talks to the user's local backend via CORS)
|
|
6
|
+
//
|
|
7
|
+
// httpBase is used by fetch(); wsBase is used by WebSocket constructions.
|
|
8
|
+
// Keep both as functions rather than constants so the values reflect
|
|
9
|
+
// `location.*` at call time (matters for tests / route changes).
|
|
10
|
+
|
|
11
|
+
function isLocal() {
|
|
12
|
+
return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function httpBase() {
|
|
16
|
+
return isLocal() ? '' : 'http://localhost:7777';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function wsBase() {
|
|
20
|
+
if (isLocal()) {
|
|
21
|
+
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
|
22
|
+
}
|
|
23
|
+
return 'ws://localhost:7777';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isHostedFrontend() {
|
|
27
|
+
return !isLocal();
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { activeTab } from '../state.js';
|
|
3
|
+
import { Sidebar } from './Sidebar.js';
|
|
4
|
+
import { PageHead } from './PageHead.js';
|
|
5
|
+
import { Footer } from './Footer.js';
|
|
6
|
+
import { Toast } from './Toast.js';
|
|
7
|
+
import { Fab } from './Fab.js';
|
|
8
|
+
import { NewSessionModal } from './NewSessionModal.js';
|
|
9
|
+
import { DialogHost } from './DialogHost.js';
|
|
10
|
+
import { OfflineBanner } from './OfflineBanner.js';
|
|
11
|
+
import { SessionsPage } from '../pages/SessionsPage.js';
|
|
12
|
+
import { LaunchPage } from '../pages/LaunchPage.js';
|
|
13
|
+
import { TerminalsPage } from '../pages/TerminalsPage.js';
|
|
14
|
+
import { ConfigurePage } from '../pages/ConfigurePage.js';
|
|
15
|
+
import { AboutPage } from '../pages/AboutPage.js';
|
|
16
|
+
|
|
17
|
+
function Panel({ name, children }) {
|
|
18
|
+
const active = activeTab.value === name;
|
|
19
|
+
return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function App() {
|
|
23
|
+
const tab = activeTab.value;
|
|
24
|
+
|
|
25
|
+
return html`
|
|
26
|
+
<div class="app">
|
|
27
|
+
<${Sidebar} />
|
|
28
|
+
<main class="main">
|
|
29
|
+
<${PageHead} />
|
|
30
|
+
<${OfflineBanner} />
|
|
31
|
+
<div class="content">
|
|
32
|
+
${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
|
|
33
|
+
${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
|
|
34
|
+
${tab === 'terminals' ? html`<${Panel} name="terminals"><${TerminalsPage} /></${Panel}>` : null}
|
|
35
|
+
${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
|
|
36
|
+
${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
|
|
37
|
+
</div>
|
|
38
|
+
<${Footer} />
|
|
39
|
+
</main>
|
|
40
|
+
<${Fab} />
|
|
41
|
+
<${NewSessionModal} />
|
|
42
|
+
<${Toast} />
|
|
43
|
+
<${DialogHost} />
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Foldable card. Pass foldKey for fold-state persistence across reloads;
|
|
2
|
+
// omit it for non-foldable cards (Launch tab, etc).
|
|
3
|
+
|
|
4
|
+
import { html } from '../html.js';
|
|
5
|
+
import { cardFolded, toggleCardFold } from '../state.js';
|
|
6
|
+
import { IconChevronDown } from '../icons.js';
|
|
7
|
+
|
|
8
|
+
export function Card({ foldKey, title, titleAfter, meta, children, flush }) {
|
|
9
|
+
const collapsed = foldKey ? !!cardFolded.value[foldKey] : false;
|
|
10
|
+
const bodyClass = flush ? 'card-body card-body-flush' : 'card-body';
|
|
11
|
+
const onHeadClick = foldKey ? () => toggleCardFold(foldKey) : undefined;
|
|
12
|
+
|
|
13
|
+
return html`
|
|
14
|
+
<article class="card" data-fold-key=${foldKey || null} data-collapsed=${collapsed || null}>
|
|
15
|
+
<header class="card-head" onClick=${onHeadClick}>
|
|
16
|
+
<div class="card-titles">
|
|
17
|
+
<h2 class="card-title">${title}${titleAfter || null}</h2>
|
|
18
|
+
${meta ? html`<p class="card-meta">${meta}</p>` : null}
|
|
19
|
+
</div>
|
|
20
|
+
${foldKey ? html`<button class="card-fold" aria-label="collapse"><${IconChevronDown} /></button>` : null}
|
|
21
|
+
</header>
|
|
22
|
+
<div class=${bodyClass}>${children}</div>
|
|
23
|
+
</article>`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Renders the dialog stack at the end of the App tree. Each entry handles
|
|
2
|
+
// its own keyboard + click dismissal lifecycle.
|
|
3
|
+
|
|
4
|
+
import { html } from '../html.js';
|
|
5
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
6
|
+
import { dialogs } from '../dialog.js';
|
|
7
|
+
|
|
8
|
+
export function DialogHost() {
|
|
9
|
+
const list = dialogs.value;
|
|
10
|
+
if (list.length === 0) return null;
|
|
11
|
+
return html`${list.map((d) => html`<${DialogShell} key=${d.id} dialog=${d} />`)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function DialogShell({ dialog }) {
|
|
15
|
+
const ref = useRef(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const onKey = (ev) => {
|
|
18
|
+
if (ev.key === 'Escape') { ev.preventDefault(); dialog.close('escape', ref.current); }
|
|
19
|
+
else if (ev.key === 'Enter') { ev.preventDefault(); dialog.close('enter', ref.current); }
|
|
20
|
+
};
|
|
21
|
+
document.addEventListener('keydown', onKey);
|
|
22
|
+
const prevOverflow = document.body.style.overflow;
|
|
23
|
+
document.body.style.overflow = 'hidden';
|
|
24
|
+
// initial focus next tick — element must already be in DOM
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
if (dialog.initialFocus) dialog.initialFocus(ref.current);
|
|
27
|
+
else ref.current?.querySelector('[data-action="ok"]')?.focus();
|
|
28
|
+
}, 50);
|
|
29
|
+
return () => {
|
|
30
|
+
document.removeEventListener('keydown', onKey);
|
|
31
|
+
document.body.style.overflow = prevOverflow;
|
|
32
|
+
};
|
|
33
|
+
}, [dialog.id]);
|
|
34
|
+
|
|
35
|
+
const onClick = (ev) => {
|
|
36
|
+
if (ev.target === ref.current) return dialog.close('cancel', ref.current);
|
|
37
|
+
const btn = ev.target.closest('button[data-action]');
|
|
38
|
+
if (btn) dialog.close(btn.dataset.action, ref.current);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return html`
|
|
42
|
+
<div ref=${ref} class="modal-backdrop" role="dialog" aria-modal="true" onClick=${onClick}>
|
|
43
|
+
${dialog.render()}
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { modalOpen } from '../state.js';
|
|
3
|
+
import { IconPlus } from '../icons.js';
|
|
4
|
+
|
|
5
|
+
export function Fab() {
|
|
6
|
+
return html`
|
|
7
|
+
<button class="fab" title="Launch new session" aria-label="Launch new session"
|
|
8
|
+
onClick=${() => (modalOpen.value = true)}>
|
|
9
|
+
<${IconPlus} />
|
|
10
|
+
</button>`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import {
|
|
3
|
+
favoritesList, favoritesOffset, favoritesLimit,
|
|
4
|
+
sessions, clockTick,
|
|
5
|
+
} from '../state.js';
|
|
6
|
+
import { fmtAgo, fmtTime } from '../util.js';
|
|
7
|
+
import { focusSession, resumeSession } from '../actions.js';
|
|
8
|
+
import { TitleCell } from './TitleCell.js';
|
|
9
|
+
import { Pagination } from './Pagination.js';
|
|
10
|
+
import { IconMonitor, IconExternal } from '../icons.js';
|
|
11
|
+
|
|
12
|
+
export function FavoritesTable() {
|
|
13
|
+
void clockTick.value;
|
|
14
|
+
const full = favoritesList.value;
|
|
15
|
+
if (favoritesOffset.value >= full.length) {
|
|
16
|
+
favoritesOffset.value = Math.max(0, Math.floor((full.length - 1) / favoritesLimit.value) * favoritesLimit.value);
|
|
17
|
+
}
|
|
18
|
+
const slice = full.slice(favoritesOffset.value, favoritesOffset.value + favoritesLimit.value);
|
|
19
|
+
|
|
20
|
+
if (full.length === 0) {
|
|
21
|
+
return html`<div class="empty" id="favoritesEmpty">No favorites yet. Star a session row to pin it here.</div>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return html`
|
|
25
|
+
<div class="table-scroll">
|
|
26
|
+
<table class="data">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th>Title</th>
|
|
30
|
+
<th>Working directory</th>
|
|
31
|
+
<th>Branch</th>
|
|
32
|
+
<th class="num">Pinned</th>
|
|
33
|
+
<th class="col-actions"></th>
|
|
34
|
+
</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody>${slice.map((f) => html`<${Row} key=${f.sessionId} fav=${f} />`)}</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
</div>
|
|
39
|
+
<${Pagination}
|
|
40
|
+
total=${full.length}
|
|
41
|
+
offset=${favoritesOffset.value}
|
|
42
|
+
limit=${favoritesLimit.value}
|
|
43
|
+
onChange=${(off, lim) => { favoritesOffset.value = off; favoritesLimit.value = lim; }} />`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Row({ fav: f }) {
|
|
47
|
+
const live = sessions.value.find((s) => s.sessionId === f.sessionId);
|
|
48
|
+
const title = live?.title || f.title;
|
|
49
|
+
const cwd = live?.cwd || f.cwd;
|
|
50
|
+
const branch = f.gitBranch;
|
|
51
|
+
const liveExtra = live ? html` · <span style="color:var(--green);">live</span>` : null;
|
|
52
|
+
const actions = live
|
|
53
|
+
? html`
|
|
54
|
+
<button class="action small" title="raise the wt window"
|
|
55
|
+
onClick=${() => focusSession(f.sessionId)}>
|
|
56
|
+
<${IconMonitor} /> Focus
|
|
57
|
+
</button>`
|
|
58
|
+
: html`
|
|
59
|
+
<button class="action small" title="claude --resume in a fresh wt window"
|
|
60
|
+
disabled=${!cwd}
|
|
61
|
+
onClick=${() => resumeSession(f.sessionId, cwd, { kind: 'continue' })}>
|
|
62
|
+
<${IconExternal} /> Continue
|
|
63
|
+
</button>`;
|
|
64
|
+
|
|
65
|
+
return html`
|
|
66
|
+
<tr>
|
|
67
|
+
<td>
|
|
68
|
+
<${TitleCell}
|
|
69
|
+
sessionId=${f.sessionId}
|
|
70
|
+
title=${title}
|
|
71
|
+
secondaryExtra=${liveExtra}
|
|
72
|
+
snapshotData=${{ cwd: cwd || '', title, gitBranch: branch || '' }} />
|
|
73
|
+
</td>
|
|
74
|
+
<td><div class="path-cell" title=${cwd || ''}>${cwd || ''}</div></td>
|
|
75
|
+
<td>
|
|
76
|
+
${branch ? html`<span class="branch-tag">${branch}</span>` : html`<span class="muted-text">—</span>`}
|
|
77
|
+
</td>
|
|
78
|
+
<td class="num" title=${fmtTime(f.addedAt)}>${fmtAgo(f.addedAt)}</td>
|
|
79
|
+
<td><div class="row-actions">${actions}</div></td>
|
|
80
|
+
</tr>`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { config } from '../state.js';
|
|
3
|
+
|
|
4
|
+
export function Footer() {
|
|
5
|
+
const cfg = config.value;
|
|
6
|
+
return html`
|
|
7
|
+
<footer class="footer-status">
|
|
8
|
+
<span class="fs-key">Data</span> <span class="fs-val">~/.ccsm</span>
|
|
9
|
+
<span class="fs-divider">·</span>
|
|
10
|
+
<span class="fs-key">Workspaces</span> <span class="fs-val">${cfg?.workDir ?? '—'}</span>
|
|
11
|
+
</footer>`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// FAB-triggered launch modal. Shares ReposEditor + RepoPicker with the
|
|
2
|
+
// inline Launch tab form, but maintains its own selected-repos + workspace
|
|
3
|
+
// state so the two don't clobber each other.
|
|
4
|
+
|
|
5
|
+
import { html } from '../html.js';
|
|
6
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
+
import { signal } from '@preact/signals';
|
|
8
|
+
import { modalOpen, config } from '../state.js';
|
|
9
|
+
import { api, loadWorkspaces } from '../api.js';
|
|
10
|
+
import { setToast } from '../toast.js';
|
|
11
|
+
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
12
|
+
import { IconClose } from '../icons.js';
|
|
13
|
+
import { RepoPicker } from './RepoPicker.js';
|
|
14
|
+
import { WorkspacePicker } from './WorkspacePicker.js';
|
|
15
|
+
import { ProgressList } from './ProgressList.js';
|
|
16
|
+
import { ReposEditor, addEmptyRepo } from './ReposEditor.js';
|
|
17
|
+
|
|
18
|
+
const ROOT_ID = 'modalProgress';
|
|
19
|
+
const modalSelected = signal(new Set());
|
|
20
|
+
|
|
21
|
+
// Top-level mounts ModalBody only when open. Keeping hooks inside ModalBody
|
|
22
|
+
// means hook count is stable across its lifetime (was previously being
|
|
23
|
+
// declared after a conditional return, which fights Preact's hook contract
|
|
24
|
+
// and makes opening feel laggy).
|
|
25
|
+
export function NewSessionModal() {
|
|
26
|
+
return modalOpen.value ? html`<${ModalBody} />` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ModalBody() {
|
|
30
|
+
const [workspace, setWorkspace] = useState('');
|
|
31
|
+
const [result, setResult] = useState('');
|
|
32
|
+
const [busy, setBusy] = useState(false);
|
|
33
|
+
const [reposSavedAt, setReposSavedAt] = useState('');
|
|
34
|
+
|
|
35
|
+
const close = () => {
|
|
36
|
+
setResult('');
|
|
37
|
+
setBusy(false);
|
|
38
|
+
modalOpen.value = false;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onKey = (ev) => { if (ev.key === 'Escape') close(); };
|
|
43
|
+
document.addEventListener('keydown', onKey);
|
|
44
|
+
const prev = document.body.style.overflow;
|
|
45
|
+
document.body.style.overflow = 'hidden';
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener('keydown', onKey);
|
|
48
|
+
document.body.style.overflow = prev;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const onLaunch = async () => {
|
|
53
|
+
const repos = [...modalSelected.value];
|
|
54
|
+
if (repos.length === 0) return setToast('select at least one repo', 'error');
|
|
55
|
+
setBusy(true);
|
|
56
|
+
setResult('');
|
|
57
|
+
resetProgress(repos, ROOT_ID);
|
|
58
|
+
try {
|
|
59
|
+
const final = await streamNewSession(
|
|
60
|
+
{ repos, workspace: workspace || undefined },
|
|
61
|
+
{
|
|
62
|
+
progressRootId: ROOT_ID,
|
|
63
|
+
onMeta: (ev) => {
|
|
64
|
+
if (ev.type === 'workspace') {
|
|
65
|
+
setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
|
|
66
|
+
} else if (ev.type === 'launched') {
|
|
67
|
+
setResult(`terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
if (final.success) {
|
|
73
|
+
const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
74
|
+
setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''} — ${summary}`);
|
|
75
|
+
setToast(`launched · ${final.workspace.name}`);
|
|
76
|
+
setTimeout(close, 1500);
|
|
77
|
+
} else {
|
|
78
|
+
setResult(`error: ${final.error}`);
|
|
79
|
+
setToast(final.error || 'new session failed', 'error');
|
|
80
|
+
}
|
|
81
|
+
await loadWorkspaces();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
setResult(`error: ${e.message}`);
|
|
84
|
+
setToast(e.message, 'error');
|
|
85
|
+
} finally {
|
|
86
|
+
setBusy(false);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onSaveRepos = async () => {
|
|
91
|
+
try {
|
|
92
|
+
const cfg = await api('PUT', '/api/config', config.value);
|
|
93
|
+
config.value = cfg;
|
|
94
|
+
setReposSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
|
|
95
|
+
setToast('repos saved');
|
|
96
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const onBackdropClick = (ev) => { if (ev.currentTarget === ev.target) close(); };
|
|
100
|
+
|
|
101
|
+
return html`
|
|
102
|
+
<div class="modal-backdrop" role="dialog" aria-modal="true" onClick=${onBackdropClick}>
|
|
103
|
+
<div class="modal">
|
|
104
|
+
<header class="modal-head">
|
|
105
|
+
<h2>Launch new session</h2>
|
|
106
|
+
<button class="modal-close" aria-label="close" onClick=${close}><${IconClose} /></button>
|
|
107
|
+
</header>
|
|
108
|
+
<div class="modal-body">
|
|
109
|
+
<p class="modal-hint">Pick an unused workspace, clone any missing repos, open <code>claude</code> in a fresh terminal.</p>
|
|
110
|
+
|
|
111
|
+
<div class="form-row">
|
|
112
|
+
<span class="form-label">Repos</span>
|
|
113
|
+
<${RepoPicker} selectedSig=${modalSelected} />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<details class="repos-inline-config">
|
|
117
|
+
<summary>Manage repos</summary>
|
|
118
|
+
<div class="repos-inline-body">
|
|
119
|
+
<${ReposEditor} />
|
|
120
|
+
<div class="repos-inline-actions">
|
|
121
|
+
<button class="action small" onClick=${() => addEmptyRepo()}>+ Add repo</button>
|
|
122
|
+
<button class="action small primary" onClick=${onSaveRepos}>Save changes</button>
|
|
123
|
+
<span class="muted-text">${reposSavedAt}</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</details>
|
|
127
|
+
|
|
128
|
+
<div class="form-row">
|
|
129
|
+
<label class="form-label">Workspace</label>
|
|
130
|
+
<${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<${ProgressList} rootId=${ROOT_ID} />
|
|
134
|
+
<div class="post-result">${result}</div>
|
|
135
|
+
</div>
|
|
136
|
+
<footer class="modal-foot">
|
|
137
|
+
<button class="action" onClick=${close}>Cancel</button>
|
|
138
|
+
<button class="action primary" disabled=${busy} onClick=${onLaunch}>Launch new session</button>
|
|
139
|
+
</footer>
|
|
140
|
+
</div>
|
|
141
|
+
</div>`;
|
|
142
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Shown when the backend probe fails. The hosted frontend (running at
|
|
2
|
+
// https://bakapiano.github.io/cssm/v1/) can't spawn processes directly,
|
|
3
|
+
// so we surface a ccsm://start link instead. Windows / OS will hand that
|
|
4
|
+
// off to the registered protocol handler (ccsm.cmd), which spawns the
|
|
5
|
+
// backend silently. Our health probe picks it up on the next tick and
|
|
6
|
+
// the banner auto-hides.
|
|
7
|
+
//
|
|
8
|
+
// First click triggers a Windows confirmation dialog ("Open ccsm.cmd?").
|
|
9
|
+
// User can check "Always allow" to suppress future prompts.
|
|
10
|
+
|
|
11
|
+
import { html } from '../html.js';
|
|
12
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
13
|
+
import { serverHealth } from '../state.js';
|
|
14
|
+
import { refreshAll } from '../api.js';
|
|
15
|
+
|
|
16
|
+
export function OfflineBanner() {
|
|
17
|
+
const h = serverHealth.value;
|
|
18
|
+
// "connecting" is the initial transient state — don't flash the banner
|
|
19
|
+
// until we've actually seen offline.
|
|
20
|
+
const offline = h.state === 'offline';
|
|
21
|
+
const [clicked, setClicked] = useState(false);
|
|
22
|
+
|
|
23
|
+
// When backend comes back online after the user tried to launch it,
|
|
24
|
+
// kick refreshAll so the page state catches up faster than the next
|
|
25
|
+
// 5s tick.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (h.state === 'online' && clicked) {
|
|
28
|
+
refreshAll().catch(() => {});
|
|
29
|
+
setClicked(false);
|
|
30
|
+
}
|
|
31
|
+
}, [h.state, clicked]);
|
|
32
|
+
|
|
33
|
+
if (!offline) return null;
|
|
34
|
+
|
|
35
|
+
return html`
|
|
36
|
+
<div class="offline-banner">
|
|
37
|
+
<div class="offline-banner-inner">
|
|
38
|
+
<span class="offline-dot" aria-hidden="true"></span>
|
|
39
|
+
<div class="offline-banner-text">
|
|
40
|
+
<strong>Backend not running.</strong>
|
|
41
|
+
<span class="muted-text">
|
|
42
|
+
Click Start to launch · Windows will ask once
|
|
43
|
+
(check "Always allow" to silence future prompts).
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="offline-banner-actions">
|
|
47
|
+
<a class="action primary" href="ccsm://start"
|
|
48
|
+
onClick=${() => setClicked(true)}>Start ccsm</a>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { activeTab, TAB_HEADINGS, lastRefreshAt, clockTick } from '../state.js';
|
|
3
|
+
import { refreshAll } from '../api.js';
|
|
4
|
+
import { setToast } from '../toast.js';
|
|
5
|
+
import { fmtAgo } from '../util.js';
|
|
6
|
+
import { ServerStatus } from './ServerStatus.js';
|
|
7
|
+
import { IconRefresh } from '../icons.js';
|
|
8
|
+
|
|
9
|
+
export function PageHead() {
|
|
10
|
+
const heading = TAB_HEADINGS[activeTab.value] || TAB_HEADINGS.sessions;
|
|
11
|
+
// subscribe to clockTick so the "Ns ago" relative label updates every second
|
|
12
|
+
void clockTick.value;
|
|
13
|
+
const last = lastRefreshAt.value;
|
|
14
|
+
const lastLabel = last ? `${fmtAgo(last)} ago` : 'never';
|
|
15
|
+
|
|
16
|
+
const onRefresh = () =>
|
|
17
|
+
refreshAll().then(() => setToast('refreshed')).catch((e) => setToast(e.message, 'error'));
|
|
18
|
+
|
|
19
|
+
return html`
|
|
20
|
+
<header class="page-head">
|
|
21
|
+
<div class="page-head-inner">
|
|
22
|
+
<h1 class="page-title">${heading.title}</h1>
|
|
23
|
+
<p class="page-subtitle">${heading.subtitle}</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="page-head-meta">
|
|
26
|
+
<${ServerStatus} />
|
|
27
|
+
<button class="action subtle small" title=${`last refresh: ${lastLabel}`} onClick=${onRefresh}>
|
|
28
|
+
<${IconRefresh} size=${13} /> Refresh
|
|
29
|
+
<span class="refresh-ago">${lastLabel}</span>
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</header>`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shared pagination footer. Hidden when total ≤ limit.
|
|
2
|
+
// onChange(nextOffset, nextLimit) is called for both arrow clicks and page-size change.
|
|
3
|
+
|
|
4
|
+
import { html } from '../html.js';
|
|
5
|
+
|
|
6
|
+
export function Pagination({ total, offset, limit, onChange }) {
|
|
7
|
+
if (total <= limit) return null;
|
|
8
|
+
const pageNum = Math.floor(offset / limit) + 1;
|
|
9
|
+
const pageTotal = Math.max(1, Math.ceil(total / limit));
|
|
10
|
+
const prev = () => onChange(Math.max(0, offset - limit), limit);
|
|
11
|
+
const next = () => onChange(offset + limit, limit);
|
|
12
|
+
const resize = (e) => onChange(0, Math.max(1, Number(e.target.value) || 10));
|
|
13
|
+
|
|
14
|
+
return html`
|
|
15
|
+
<footer class="pagination">
|
|
16
|
+
<button class="action subtle small" disabled=${offset === 0} onClick=${prev}>← Prev</button>
|
|
17
|
+
<span class="pagination-info">
|
|
18
|
+
Page <strong>${pageNum}</strong> of <strong>${pageTotal}</strong> · <span>${total}</span> total
|
|
19
|
+
</span>
|
|
20
|
+
<button class="action subtle small" disabled=${offset + limit >= total} onClick=${next}>Next →</button>
|
|
21
|
+
<select class="input" style="max-width: 100px;" value=${limit} onChange=${resize}>
|
|
22
|
+
<option value="10">10 / page</option>
|
|
23
|
+
<option value="20">20 / page</option>
|
|
24
|
+
<option value="50">50 / page</option>
|
|
25
|
+
</select>
|
|
26
|
+
</footer>`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// One repo per row — driven by the streaming.js progress signal.
|
|
2
|
+
|
|
3
|
+
import { html } from '../html.js';
|
|
4
|
+
import { progressByContext } from '../streaming.js';
|
|
5
|
+
|
|
6
|
+
export function ProgressList({ rootId }) {
|
|
7
|
+
const map = progressByContext.value[rootId] || {};
|
|
8
|
+
const items = Object.values(map);
|
|
9
|
+
if (items.length === 0) return null;
|
|
10
|
+
|
|
11
|
+
return html`
|
|
12
|
+
<div class="progress-list">
|
|
13
|
+
${items.map((it) => html`<${Item} key=${it.name} item=${it} />`)}
|
|
14
|
+
</div>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function Item({ item }) {
|
|
18
|
+
const cls = `progress-item${item.state ? ' ' + item.state : ''}`;
|
|
19
|
+
const fillCls = `fill${item.indeterminate ? ' indeterminate' : ''}`;
|
|
20
|
+
const fillStyle = item.percent != null ? `width: ${item.percent}%` : '';
|
|
21
|
+
const pct = item.percent != null ? `${item.percent}%` : '';
|
|
22
|
+
return html`
|
|
23
|
+
<div class=${cls}>
|
|
24
|
+
<div class="head">
|
|
25
|
+
<span class="name">${item.name}</span>
|
|
26
|
+
<span class="phase">${item.phase}</span>
|
|
27
|
+
<span class="pct">${pct}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="progress-bar"><div class=${fillCls} style=${fillStyle}></div></div>
|
|
30
|
+
<div class="detail">${item.detail || ''}</div>
|
|
31
|
+
</div>`;
|
|
32
|
+
}
|