@bakapiano/ccsm 0.8.4 → 0.10.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.
Files changed (70) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +78 -80
  3. package/bin/ccsm.js +1 -1
  4. package/lib/cliSessionWatcher.js +249 -0
  5. package/lib/config.js +101 -19
  6. package/lib/folders.js +96 -0
  7. package/lib/localCliSessions.js +177 -0
  8. package/lib/persistedSessions.js +134 -0
  9. package/lib/webTerminal.js +48 -13
  10. package/lib/workspace.js +26 -4
  11. package/package.json +4 -4
  12. package/public/assets/claude-color.svg +1 -0
  13. package/public/assets/codex-color.svg +1 -0
  14. package/public/assets/copilot-color.svg +1 -0
  15. package/public/css/base.css +22 -5
  16. package/public/css/cards.css +37 -3
  17. package/public/css/feedback.css +127 -43
  18. package/public/css/forms.css +133 -10
  19. package/public/css/layout.css +79 -26
  20. package/public/css/modal.css +40 -26
  21. package/public/css/responsive.css +2 -2
  22. package/public/css/sidebar.css +456 -20
  23. package/public/css/terminals.css +182 -0
  24. package/public/css/tokens.css +28 -12
  25. package/public/css/wco.css +47 -19
  26. package/public/css/widgets.css +1177 -6
  27. package/public/index.html +39 -4
  28. package/public/js/api.js +194 -37
  29. package/public/js/components/AdoptModal.js +171 -0
  30. package/public/js/components/App.js +1 -11
  31. package/public/js/components/DirectoryPicker.js +203 -0
  32. package/public/js/components/EntityFormModal.js +105 -0
  33. package/public/js/components/Modal.js +51 -0
  34. package/public/js/components/OfflineBanner.js +29 -23
  35. package/public/js/components/PageTitleBar.js +13 -0
  36. package/public/js/components/Picker.js +179 -0
  37. package/public/js/components/Popover.js +55 -0
  38. package/public/js/components/Sidebar.js +244 -26
  39. package/public/js/components/TerminalView.js +192 -2
  40. package/public/js/components/useDragSort.js +67 -0
  41. package/public/js/dialog.js +10 -2
  42. package/public/js/icons.js +66 -3
  43. package/public/js/main.js +54 -3
  44. package/public/js/pages/AboutPage.js +81 -1
  45. package/public/js/pages/ConfigurePage.js +452 -159
  46. package/public/js/pages/LaunchPage.js +328 -76
  47. package/public/js/pages/SessionsPage.js +91 -41
  48. package/public/js/state.js +179 -35
  49. package/public/manifest.webmanifest +2 -2
  50. package/scripts/install.js +1 -1
  51. package/server.js +763 -407
  52. package/lib/favorites.js +0 -51
  53. package/lib/focus.js +0 -369
  54. package/lib/labels.js +0 -29
  55. package/lib/launcher.js +0 -219
  56. package/lib/sessions.js +0 -272
  57. package/lib/snapshot.js +0 -141
  58. package/public/js/actions.js +0 -87
  59. package/public/js/components/Fab.js +0 -11
  60. package/public/js/components/FavoritesTable.js +0 -81
  61. package/public/js/components/Footer.js +0 -12
  62. package/public/js/components/NewSessionModal.js +0 -142
  63. package/public/js/components/PageHead.js +0 -33
  64. package/public/js/components/Pagination.js +0 -27
  65. package/public/js/components/RecentTable.js +0 -68
  66. package/public/js/components/SessionsTable.js +0 -71
  67. package/public/js/components/SnapshotPanel.js +0 -77
  68. package/public/js/components/TitleCell.js +0 -40
  69. package/public/js/components/WorkspacesGrid.js +0 -41
  70. package/public/js/pages/TerminalsPage.js +0 -74
@@ -18,11 +18,19 @@ function push(entry) {
18
18
  });
19
19
  }
20
20
 
21
+ const CLOSE_X = html`
22
+ <button class="modal-close" type="button" aria-label="Close" data-action="cancel">
23
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
24
+ <line x1="3" y1="3" x2="13" y2="13"/>
25
+ <line x1="13" y1="3" x2="3" y2="13"/>
26
+ </svg>
27
+ </button>`;
28
+
21
29
  export function ccsmConfirm(message, opts = {}) {
22
30
  const { title = 'Confirm', okLabel = 'Confirm', cancelLabel = 'Cancel', danger = false } = opts;
23
31
  return push({
24
32
  render: () => html`<div class="modal modal-dialog">
25
- <header class="modal-head"><h2>${title}</h2></header>
33
+ <header class="modal-head"><h2>${title}</h2>${CLOSE_X}</header>
26
34
  <div class="modal-body"><p class="dialog-msg">${message}</p></div>
27
35
  <footer class="modal-foot">
28
36
  <button class="action" data-action="cancel">${cancelLabel}</button>
@@ -37,7 +45,7 @@ export function ccsmPrompt(message, defaultValue = '', opts = {}) {
37
45
  const { title, okLabel = 'Save', cancelLabel = 'Cancel', placeholder = '' } = opts;
38
46
  return push({
39
47
  render: () => html`<div class="modal modal-dialog">
40
- <header class="modal-head"><h2>${title || message}</h2></header>
48
+ <header class="modal-head"><h2>${title || message}</h2>${CLOSE_X}</header>
41
49
  <div class="modal-body">
42
50
  ${title ? html`<p class="dialog-msg">${message}</p>` : null}
43
51
  <input type="text" class="input" placeholder=${placeholder} value=${defaultValue} />
@@ -29,7 +29,33 @@ export const IconRefresh = ic('0 0 24 24', html`
29
29
  `, 16);
30
30
 
31
31
  export const IconChevronLeft = ic('0 0 24 24', html`<polyline points="15 18 9 12 15 6"/>`, 14);
32
+ export const IconChevronRight = ic('0 0 24 24', html`<polyline points="9 18 15 12 9 6"/>`, 14);
33
+ export const IconChevronUp = ic('0 0 24 24', html`<polyline points="18 15 12 9 6 15"/>`, 14);
32
34
  export const IconChevronDown = ic('0 0 24 24', html`<polyline points="6 9 12 15 18 9"/>`, 14);
35
+ export const IconArrowRight = ic('0 0 24 24', html`<line x1="5" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18"/>`, 14);
36
+ export const IconHome = ic('0 0 24 24', html`
37
+ <path d="M3 11l9-8 9 8"/>
38
+ <path d="M5 10v10a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V10"/>
39
+ `, 14);
40
+ export const IconSparkle = ic('0 0 24 24', html`
41
+ <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>
42
+ <path d="M19 17l.8 1.6L21 20l-1.2.4L19 22l-.8-1.6L17 20l1.2-.4z"/>
43
+ `, 18);
44
+ // "Workspace" — stacked layers / cube. Used for the launch-page
45
+ // destination pill so it doesn't clash with the folder-tag pill that
46
+ // uses IconFolder.
47
+ export const IconWorkspace = ic('0 0 24 24', html`
48
+ <path d="M12 2l9 5-9 5-9-5z"/>
49
+ <path d="M3 12l9 5 9-5"/>
50
+ <path d="M3 17l9 5 9-5"/>
51
+ `, 16);
52
+ // Sidebar-toggle icon (panel-left). A rectangle with a vertical divider
53
+ // near the left — universally recognised "show/hide sidebar" affordance
54
+ // (Notion, Codex, Linear all use this shape).
55
+ export const IconSidebarToggle = ic('0 0 24 24', html`
56
+ <rect x="3" y="4" width="18" height="16" rx="2"/>
57
+ <line x1="9" y1="4" x2="9" y2="20"/>
58
+ `, 14);
33
59
 
34
60
  export const IconSearch = ic('0 0 24 24', html`
35
61
  <circle cx="11" cy="11" r="7"/>
@@ -46,6 +72,17 @@ export const IconPlus = ic('0 0 24 24', html`
46
72
  <line x1="5" y1="12" x2="19" y2="12"/>
47
73
  `, 22);
48
74
 
75
+ // Folder + folder-open. Used in the sidebar session tree to mirror the
76
+ // icon-first style of the top nav items. Open variant for expanded
77
+ // folders so the chevron isn't doing double duty.
78
+ export const IconFolder = ic('0 0 24 24', html`
79
+ <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
80
+ `, 16);
81
+ export const IconFolderOpen = ic('0 0 24 24', html`
82
+ <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v1H3V7z"/>
83
+ <path d="M3 10h18l-2 7a2 2 0 0 1-2 1.5H5A2 2 0 0 1 3 17V10z"/>
84
+ `, 16);
85
+
49
86
  export const IconPencil = ic('0 0 24 24', html`
50
87
  <path d="M12 20h9"/>
51
88
  <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
@@ -81,6 +118,31 @@ export const IconTerminal = ic('0 0 24 24', html`
81
118
  <line x1="12" y1="19" x2="20" y2="19"/>
82
119
  `, 18);
83
120
 
121
+ // Git branch — for repo selection
122
+ export const IconBranch = ic('0 0 24 24', html`
123
+ <line x1="6" y1="3" x2="6" y2="15"/>
124
+ <circle cx="18" cy="6" r="3"/>
125
+ <circle cx="6" cy="18" r="3"/>
126
+ <path d="M18 9a9 9 0 0 1-9 9"/>
127
+ `, 18);
128
+
129
+ // Brand-colored CLI marks. These use external SVG assets (full color),
130
+ // rendered as <img> so the gradients / fills in the file are preserved.
131
+ export const IconClaudeColor = () => html`
132
+ <img src="./assets/claude-color.svg" alt="" width="18" height="18" style="display:block" />`;
133
+ export const IconCodexColor = () => html`
134
+ <img src="./assets/codex-color.svg" alt="" width="18" height="18" style="display:block" />`;
135
+ export const IconCopilotColor = () => html`
136
+ <img src="./assets/copilot-color.svg" alt="" width="18" height="18" style="display:block" />`;
137
+
138
+ // Pick the right icon for a CLI based on its type field.
139
+ export const IconForCliType = (type) => {
140
+ if (type === 'claude') return IconClaudeColor;
141
+ if (type === 'codex') return IconCodexColor;
142
+ if (type === 'copilot') return IconCopilotColor;
143
+ return IconTerminal;
144
+ };
145
+
84
146
  // Two variants used in the StarButton.
85
147
  export const StarOutline = ({ size = 15 } = {}) => html`
86
148
  <svg viewBox="0 0 24 24" width=${size} height=${size} fill="none" stroke="currentColor"
@@ -105,9 +167,10 @@ export const BrandMark = () => html`
105
167
  <svg viewBox="0 0 32 32" width="32" height="32">
106
168
  <rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
107
169
  <line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
108
- <circle cx="6" cy="7" r="1" fill="#faf9f5"/>
109
- <circle cx="9.5" cy="7" r="1" fill="#faf9f5" opacity="0.65"/>
110
- <circle cx="13" cy="7" r="1" fill="#faf9f5" opacity="0.4"/>
170
+ <!-- macOS traffic-light style: red / yellow / green -->
171
+ <circle cx="6" cy="7" r="1" fill="#ed6a5e"/>
172
+ <circle cx="9.5" cy="7" r="1" fill="#f4be4f"/>
173
+ <circle cx="13" cy="7" r="1" fill="#62c554"/>
111
174
  <text x="16" y="19.5" text-anchor="middle" dominant-baseline="central"
112
175
  font-family="'JetBrains Mono', 'Cascadia Mono', 'Consolas', monospace"
113
176
  font-weight="700" font-size="10" fill="#faf9f5">ccsm</text>
package/public/js/main.js CHANGED
@@ -4,13 +4,23 @@
4
4
 
5
5
  import { render } from 'preact';
6
6
  import { html } from './html.js';
7
- import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa } from './state.js';
7
+ import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed } from './state.js';
8
8
  import { httpBase } from './backend.js';
9
- import { loadConfig, refreshAll, loadSessions, loadRecent, loadSnapshot, loadWorkspaces, loadWebTerminals, pollHealth } from './api.js';
9
+ import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
10
10
  import { setToast } from './toast.js';
11
11
  import { App } from './components/App.js';
12
12
 
13
13
  loadPersisted();
14
+ // Pin the document title to "CCSM" — some Chromium builds will inject the
15
+ // current URL or path into the standalone window title bar if the page
16
+ // title is empty / changes; locking it here keeps the OS title bar text
17
+ // stable across navigation, tab switches, and PWA-install refresh.
18
+ const lockTitle = () => { if (document.title !== 'CCSM') document.title = 'CCSM'; };
19
+ lockTitle();
20
+ new MutationObserver(lockTitle).observe(
21
+ document.querySelector('title') || document.head,
22
+ { childList: true, subtree: true, characterData: true }
23
+ );
14
24
  render(html`<${App} />`, document.getElementById('app'));
15
25
 
16
26
  // PWA install affordance — Chromium fires `beforeinstallprompt` when the
@@ -43,7 +53,22 @@ function applyIsAppClass() {
43
53
  applyIsAppClass();
44
54
  matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
45
55
 
56
+ // Force-collapse the sidebar on narrow viewports. Mirrors the responsive
57
+ // CSS so JS state (toggle visibility, tree-render gating) agrees with the
58
+ // rendered layout.
59
+ const narrowMq = matchMedia('(max-width: 900px)');
60
+ function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
61
+ applyNarrow();
62
+ narrowMq.addEventListener('change', applyNarrow);
63
+
46
64
  (async () => {
65
+ // Version-mismatch guard runs FIRST. If the user's backend has been
66
+ // upgraded since this per-version frontend was loaded, bounce back to
67
+ // the router immediately — no point loading config from a server that
68
+ // speaks a different API revision. Runs in dev too (it no-ops without
69
+ // the build-time <meta>).
70
+ await bootVersionGuard();
71
+
47
72
  try {
48
73
  await loadConfig();
49
74
  await refreshAll();
@@ -61,7 +86,7 @@ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass
61
86
  // move in/out of a workspace silently and the grid stays stale.
62
87
  setInterval(async () => {
63
88
  try {
64
- await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadWebTerminals()]);
89
+ await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
65
90
  lastRefreshAt.value = Date.now();
66
91
  } catch { /* swallow — next tick retries */ }
67
92
  pollHealth();
@@ -79,3 +104,29 @@ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass
79
104
  setInterval(ping, 10_000);
80
105
  document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
81
106
  })();
107
+
108
+ // ─── version routing guard ───────────────────────────────────────────
109
+ // Each deployed frontend is pinned to one backend version. The GH-Pages
110
+ // workflow bakes the version into <meta name="ccsm-frontend-version">
111
+ // so we can detect "backend has been upgraded since this frontend was
112
+ // loaded" and bounce back through the router at /ccsm/ for a fresh
113
+ // match. In dev (no meta tag, same-origin served-by-backend), the check
114
+ // no-ops — we're always running the frontend that ships with this
115
+ // backend by definition.
116
+ async function bootVersionGuard() {
117
+ const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
118
+ if (!meta) return; // dev mode
119
+ const myVer = meta.getAttribute('content');
120
+ if (!myVer) return;
121
+ let backendVer = null;
122
+ try {
123
+ const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
124
+ if (!r.ok) return;
125
+ backendVer = (await r.json()).version;
126
+ } catch { return; } // offline → OfflineBanner takes over
127
+ if (!backendVer || backendVer === myVer) return;
128
+ // Mismatch. Bounce up one level to the router. The router will
129
+ // probe /api/health again and redirect to ./<backendVer>/.
130
+ console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
131
+ location.replace('../');
132
+ }
@@ -1,10 +1,13 @@
1
1
  import { html } from '../html.js';
2
+ import { useEffect, useState } from 'preact/hooks';
2
3
  import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
3
4
  import { setToast } from '../toast.js';
5
+ import { api } from '../api.js';
4
6
  import { Card } from '../components/Card.js';
7
+ import { PageTitleBar } from '../components/PageTitleBar.js';
5
8
  import { BrandMark, IconGithub, IconExternal } from '../icons.js';
6
9
 
7
- const REPO_URL = 'https://github.com/bakapiano/cssm';
10
+ const REPO_URL = 'https://github.com/bakapiano/ccsm';
8
11
  const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
9
12
 
10
13
  async function onInstall() {
@@ -40,10 +43,87 @@ function InstallCard() {
40
43
  </${Card}>`;
41
44
  }
42
45
 
46
+ function UpgradeCard() {
47
+ const [info, setInfo] = useState(null); // { current, latest, updateAvailable, fetchedAt, error? }
48
+ const [checking, setChecking] = useState(true);
49
+ const [upgrading, setUpgrading] = useState(false);
50
+
51
+ const refresh = async (force = false) => {
52
+ setChecking(true);
53
+ try {
54
+ const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
55
+ setInfo(r);
56
+ } catch (e) {
57
+ setInfo({ error: e.message });
58
+ } finally {
59
+ setChecking(false);
60
+ }
61
+ };
62
+ useEffect(() => { refresh(false); }, []);
63
+
64
+ const onUpgrade = async () => {
65
+ if (!info?.updateAvailable) return;
66
+ setUpgrading(true);
67
+ try {
68
+ await api('POST', '/api/upgrade', { target: 'latest' });
69
+ setToast(`upgrading to v${info.latest} · backend will restart`);
70
+ } catch (e) {
71
+ setUpgrading(false);
72
+ setToast(e.message, 'error');
73
+ }
74
+ // No "finally" reset — the server is about to shut down, and the
75
+ // OfflineBanner takes over UI. When the router reroutes us to the new
76
+ // version's frontend, this component re-mounts fresh.
77
+ };
78
+
79
+ const current = info?.current || serverHealth.value.version || '';
80
+ const latest = info?.latest;
81
+ const updateAvailable = !!info?.updateAvailable;
82
+
83
+ return html`
84
+ <${Card} title="Version">
85
+ <div class="about-version-row">
86
+ <div>
87
+ <div class="about-version-line">
88
+ Installed · <span class="mono">v${current || '?'}</span>
89
+ </div>
90
+ ${latest && !updateAvailable ? html`
91
+ <div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
92
+ ` : null}
93
+ ${updateAvailable ? html`
94
+ <div class="about-update-line">
95
+ Update available · <span class="mono">v${latest}</span>
96
+ </div>
97
+ ` : null}
98
+ ${info?.error ? html`
99
+ <div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
100
+ ` : null}
101
+ </div>
102
+ <div class="about-version-actions">
103
+ <button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
104
+ ${checking ? 'Checking…' : 'Check'}
105
+ </button>
106
+ ${updateAvailable ? html`
107
+ <button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
108
+ ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
109
+ </button>
110
+ ` : null}
111
+ </div>
112
+ </div>
113
+ ${upgrading ? html`
114
+ <p class="muted-text" style="margin-top:var(--s-3)">
115
+ Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
116
+ </p>
117
+ ` : null}
118
+ </${Card}>`;
119
+ }
120
+
43
121
  export function AboutPage() {
44
122
  const version = serverHealth.value.version;
45
123
 
46
124
  return html`
125
+ <${PageTitleBar} title="About" />
126
+ <${UpgradeCard} />
47
127
  <${InstallCard} />
48
128
  <${Card} title="ccsm">
49
129
  <div class="about-block">