claude-simple 0.1.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/README.md +225 -0
- package/client.js +139 -0
- package/dist/addon-fit.min.js +2 -0
- package/dist/app.0.1.0.min.js +825 -0
- package/dist/highlight.min.css +10 -0
- package/dist/highlight.min.js +1244 -0
- package/dist/index.html +16 -0
- package/dist/marked.min.js +6 -0
- package/dist/style.0.1.0.min.css +1050 -0
- package/dist/xterm.min.css +209 -0
- package/dist/xterm.min.js +2 -0
- package/gateway.js +349 -0
- package/package.json +41 -0
- package/public/addon-fit.min.js +2 -0
- package/public/agent-panel.js +1079 -0
- package/public/api.js +78 -0
- package/public/app.js +95 -0
- package/public/base.css +4 -0
- package/public/cache.js +27 -0
- package/public/chat.js +1087 -0
- package/public/files.js +476 -0
- package/public/git.js +130 -0
- package/public/index.html +61 -0
- package/public/lib.js +2 -0
- package/public/router.js +71 -0
- package/public/settings.js +90 -0
- package/public/shell/boot.js +159 -0
- package/public/shell/dashboard.js +269 -0
- package/public/shell/hub.js +192 -0
- package/public/shell/layout.js +195 -0
- package/public/shell/notes.js +323 -0
- package/public/shell/session-context.js +19 -0
- package/public/shell/session-list.js +442 -0
- package/public/shell/session-nav.js +299 -0
- package/public/shell/templates.js +342 -0
- package/public/shell/util.js +63 -0
- package/public/shell.js +589 -0
- package/public/state.js +362 -0
- package/public/style.css +1048 -0
- package/public/terminal.js +177 -0
- package/public/xterm.min.css +209 -0
- package/public/xterm.min.js +2 -0
- package/server.js +12 -0
- package/src/agents/claude.js +58 -0
- package/src/agents/codex.js +284 -0
- package/src/agents/grok.js +318 -0
- package/src/agents/meta-agent.js +1952 -0
- package/src/agents/models.js +339 -0
- package/src/agents/sessions.js +1508 -0
- package/src/app.js +1282 -0
package/public/router.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// router.js — Hash routing: #/ #/session/:id #/session/:id/:tab
|
|
2
|
+
import { createRouter, watch } from './lib.js';
|
|
3
|
+
import { currentTab, ctx } from './state.js';
|
|
4
|
+
|
|
5
|
+
export const router = createRouter({
|
|
6
|
+
'/': () => {},
|
|
7
|
+
'/session/*': () => {},
|
|
8
|
+
'/session/*/*': () => {},
|
|
9
|
+
'*': () => {},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
let _resumeSession = null;
|
|
13
|
+
let _switchTab = null;
|
|
14
|
+
let _ignoreNext = false; // prevent feedback loop when we set hash programmatically
|
|
15
|
+
|
|
16
|
+
// Called by app.js after shell+chat are initialised
|
|
17
|
+
export function initRouter(resumeSession, switchTab) {
|
|
18
|
+
_resumeSession = resumeSession;
|
|
19
|
+
_switchTab = switchTab;
|
|
20
|
+
|
|
21
|
+
// Handle hash changes (back/forward/manual)
|
|
22
|
+
window.addEventListener('hashchange', () => {
|
|
23
|
+
if (_ignoreNext) { _ignoreNext = false; return; }
|
|
24
|
+
applyHash(location.hash.slice(1) || '/');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Keep hash in sync when session/tab changes
|
|
28
|
+
watch(currentTab, tab => {
|
|
29
|
+
if (ctx.sessionId) setHash(`/session/${ctx.sessionId}/${tab}`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse `#/session/:id/:tab?` → { id, tab } or null */
|
|
34
|
+
export function parseHashPath(path) {
|
|
35
|
+
const m = (path || '').match(/^\/session\/([^/]+)(?:\/([^/]+))?$/);
|
|
36
|
+
if (!m) return null;
|
|
37
|
+
return { id: m[1], tab: m[2] || null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Called by app.js when hash is not already handled by showApp deep-link
|
|
41
|
+
export function applyInitialRoute() {
|
|
42
|
+
applyHash(location.hash.slice(1) || '/');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function applyHash(path) {
|
|
46
|
+
// /session/:id or /session/:id/:tab
|
|
47
|
+
const parsed = parseHashPath(path);
|
|
48
|
+
if (!parsed) {
|
|
49
|
+
document.dispatchEvent(new CustomEvent('router:home'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const { id, tab } = parsed;
|
|
53
|
+
if (!_resumeSession || !_switchTab) return;
|
|
54
|
+
|
|
55
|
+
if (id !== ctx.sessionId) {
|
|
56
|
+
// Need to look up cwd from loaded sessions — defer to shell
|
|
57
|
+
document.dispatchEvent(new CustomEvent('router:session', { detail: { id, tab } }));
|
|
58
|
+
} else if (tab && tab !== currentTab.peek()) {
|
|
59
|
+
_switchTab(tab);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setHash(path) {
|
|
64
|
+
_ignoreNext = true;
|
|
65
|
+
location.hash = path;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Convenience: call after resumeSession completes to set URL
|
|
69
|
+
export function syncHash() {
|
|
70
|
+
if (ctx.sessionId) setHash(`/session/${ctx.sessionId}/${currentTab.peek()}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// settings.js — Workspace settings modal
|
|
2
|
+
import { esc, $ } from './lib.js';
|
|
3
|
+
import { api } from './api.js';
|
|
4
|
+
import { workspacesData } from './state.js';
|
|
5
|
+
import { setCachedWorkspaces } from './cache.js';
|
|
6
|
+
|
|
7
|
+
export function initSettings() {
|
|
8
|
+
document.getElementById('root').insertAdjacentHTML('beforeend', `
|
|
9
|
+
<dialog id="modal-settings" class="modal">
|
|
10
|
+
<div class="modal-box max-w-lg">
|
|
11
|
+
<h3 class="font-bold text-base mb-4">⚙️ Settings — Workspaces</h3>
|
|
12
|
+
<div id="ws-list" class="flex flex-col gap-2 mb-4"></div>
|
|
13
|
+
<div class="border-t border-base-300 pt-4">
|
|
14
|
+
<div class="text-xs uppercase tracking-wider text-base-content/50 mb-2">Add workspace</div>
|
|
15
|
+
<div class="flex gap-2 mb-2">
|
|
16
|
+
<input id="ws-new-name" type="text" placeholder="Name (e.g. personal)" class="input input-sm input-bordered flex-1">
|
|
17
|
+
<input id="ws-new-dir" type="text" placeholder="~/.claude-personal" class="input input-sm input-bordered flex-[2]">
|
|
18
|
+
<button id="ws-add-btn" class="btn btn-primary btn-sm">Add</button>
|
|
19
|
+
</div>
|
|
20
|
+
<div id="ws-error" class="alert alert-error text-xs py-1.5 hidden"></div>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="modal-action">
|
|
23
|
+
<form method="dialog"><button class="btn btn-ghost btn-sm">Close</button></form>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
27
|
+
</dialog>`);
|
|
28
|
+
|
|
29
|
+
renderWsList();
|
|
30
|
+
|
|
31
|
+
document.getElementById('ws-add-btn').addEventListener('click', addWorkspace);
|
|
32
|
+
document.getElementById('ws-new-name').addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
|
|
33
|
+
document.getElementById('ws-new-dir').addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderWsList() {
|
|
37
|
+
const el = $('ws-list');
|
|
38
|
+
if (!el) return;
|
|
39
|
+
const wss = workspacesData.peek();
|
|
40
|
+
if (!wss.length) { el.innerHTML = '<div class="text-xs text-base-content/40">No workspaces configured</div>'; return; }
|
|
41
|
+
el.innerHTML = wss.map(w => `
|
|
42
|
+
<div class="flex items-center gap-2 px-3 py-2 bg-base-200 rounded-lg">
|
|
43
|
+
<div class="flex-1 min-w-0">
|
|
44
|
+
<div class="text-sm font-medium">${esc(w.name)}</div>
|
|
45
|
+
<div class="text-xs font-mono text-base-content/50 truncate">${esc(w.configDir)}</div>
|
|
46
|
+
</div>
|
|
47
|
+
${wss.length > 1
|
|
48
|
+
? `<button class="btn btn-ghost btn-xs text-error" data-ws-delete="${esc(w.id)}">✕</button>`
|
|
49
|
+
: '<span class="text-[10px] text-base-content/30">default</span>'}
|
|
50
|
+
</div>`).join('');
|
|
51
|
+
|
|
52
|
+
el.querySelectorAll('[data-ws-delete]').forEach(btn => {
|
|
53
|
+
btn.addEventListener('click', () => deleteWorkspace(btn.dataset.wsDelete));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function addWorkspace() {
|
|
58
|
+
const name = $('ws-new-name').value.trim();
|
|
59
|
+
const dir = $('ws-new-dir').value.trim();
|
|
60
|
+
const errEl = $('ws-error');
|
|
61
|
+
errEl.classList.add('hidden');
|
|
62
|
+
if (!name || !dir) { errEl.textContent = 'Name and path are required'; errEl.classList.remove('hidden'); return; }
|
|
63
|
+
try {
|
|
64
|
+
const ws = await api('POST', '/api/workspaces', { name, configDir: dir });
|
|
65
|
+
workspacesData.value = [...workspacesData.peek(), ws];
|
|
66
|
+
setCachedWorkspaces(workspacesData.peek());
|
|
67
|
+
$('ws-new-name').value = '';
|
|
68
|
+
$('ws-new-dir').value = '';
|
|
69
|
+
renderWsList();
|
|
70
|
+
} catch (e) {
|
|
71
|
+
errEl.textContent = e.message;
|
|
72
|
+
errEl.classList.remove('hidden');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function deleteWorkspace(id) {
|
|
77
|
+
try {
|
|
78
|
+
await api('DELETE', `/api/workspaces/${id}`);
|
|
79
|
+
workspacesData.value = workspacesData.peek().filter(w => w.id !== id);
|
|
80
|
+
setCachedWorkspaces(workspacesData.peek());
|
|
81
|
+
renderWsList();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
alert(e.message);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function openSettings() {
|
|
88
|
+
renderWsList();
|
|
89
|
+
$('modal-settings')?.showModal();
|
|
90
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// shell/boot.js — showApp after login
|
|
2
|
+
import { $ } from '../lib.js';
|
|
3
|
+
import {
|
|
4
|
+
hubMode, machinesList, hubMachineReady, setSelectedMachine, sessionsData, ctx,
|
|
5
|
+
} from '../state.js';
|
|
6
|
+
import { probeHub } from '../api.js';
|
|
7
|
+
import { getCachedSessions } from '../cache.js';
|
|
8
|
+
import { getLastSessionContext } from './session-context.js';
|
|
9
|
+
import { loadAllSessions, loadWorkspaces, loadAgentsMeta } from './session-list.js';
|
|
10
|
+
import { loadSessionMetaMap } from './notes.js';
|
|
11
|
+
import { resumeSession } from './session-nav.js';
|
|
12
|
+
import {
|
|
13
|
+
refreshMachinesList, hideMachinePicker, showMachinePicker,
|
|
14
|
+
syncTopbarMachine, startMachinePolling,
|
|
15
|
+
} from './hub.js';
|
|
16
|
+
|
|
17
|
+
export async function showApp(opts = {}) {
|
|
18
|
+
$('auth-screen').style.display = 'none';
|
|
19
|
+
const app = $('app');
|
|
20
|
+
app.classList.remove('hidden');
|
|
21
|
+
app.style.display = 'flex';
|
|
22
|
+
|
|
23
|
+
// Use probe result from boot when provided (avoid second /api/hub round-trip)
|
|
24
|
+
let hub = opts.hub;
|
|
25
|
+
if (hub === undefined) {
|
|
26
|
+
hub = await probeHub();
|
|
27
|
+
} else if (hub) {
|
|
28
|
+
hubMode.value = true;
|
|
29
|
+
if (hub.machines) machinesList.value = hub.machines;
|
|
30
|
+
} else {
|
|
31
|
+
hubMode.value = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const deep = opts.deepLink || null;
|
|
35
|
+
const last = getLastSessionContext();
|
|
36
|
+
|
|
37
|
+
// Keep welcome hidden while restoring (prevents calendar flash)
|
|
38
|
+
const restoring = !!(deep?.id || (location.hash || '').startsWith('#/session/'));
|
|
39
|
+
if (restoring) {
|
|
40
|
+
const welcome = $('welcome');
|
|
41
|
+
if (welcome) {
|
|
42
|
+
welcome.classList.add('hidden');
|
|
43
|
+
welcome.style.display = 'none';
|
|
44
|
+
}
|
|
45
|
+
const pv = $('project-view');
|
|
46
|
+
if (pv) {
|
|
47
|
+
pv.classList.remove('hidden');
|
|
48
|
+
pv.style.display = 'flex';
|
|
49
|
+
pv.style.flexDirection = 'column';
|
|
50
|
+
pv.style.flex = '1';
|
|
51
|
+
pv.style.minHeight = '0';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (hubMode.peek()) {
|
|
56
|
+
const remembered = localStorage.getItem('machineId') || last?.machineId || '';
|
|
57
|
+
// Optimistic: use remembered machine immediately (don't wait for machines list)
|
|
58
|
+
if (remembered) {
|
|
59
|
+
setSelectedMachine(remembered);
|
|
60
|
+
hideMachinePicker();
|
|
61
|
+
hubMachineReady.value = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Machines list: refresh in background; only block picker if no remembered id
|
|
65
|
+
const machinesP = refreshMachinesList().then(ms => {
|
|
66
|
+
syncTopbarMachine();
|
|
67
|
+
if (!remembered) return ms;
|
|
68
|
+
const online = ms.some(m => m.id === remembered && m.online !== false);
|
|
69
|
+
if (!online && ms.length) {
|
|
70
|
+
// remembered offline — user can switch from menu; keep selection unless gone entirely
|
|
71
|
+
const exists = ms.some(m => m.id === remembered);
|
|
72
|
+
if (!exists) {
|
|
73
|
+
setSelectedMachine(null);
|
|
74
|
+
showMachinePicker();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return ms;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
startMachinePolling();
|
|
81
|
+
syncTopbarMachine();
|
|
82
|
+
|
|
83
|
+
// Sessions/workspaces/models: paint cache fast, refresh bg — don't block deep-link
|
|
84
|
+
const dataP = Promise.all([
|
|
85
|
+
loadAllSessions({ waitFresh: false }),
|
|
86
|
+
loadWorkspaces({ waitFresh: false }),
|
|
87
|
+
loadSessionMetaMap().catch(() => {}),
|
|
88
|
+
loadAgentsMeta().catch(() => {}),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// Deep-link ASAP using lastSessionContext / list cache
|
|
92
|
+
if (deep?.id) {
|
|
93
|
+
const s = sessionsData.peek().find(x => x.sessionId === deep.id)
|
|
94
|
+
|| (last?.sessionId === deep.id ? last : null);
|
|
95
|
+
await resumeSession(
|
|
96
|
+
deep.id,
|
|
97
|
+
s?.cwd || last?.cwd || null,
|
|
98
|
+
s?.configDir || last?.configDir || null,
|
|
99
|
+
s?.agent || last?.agent || null,
|
|
100
|
+
s?.machineId || last?.machineId || remembered || null,
|
|
101
|
+
{ tab: deep.tab || 'chat' },
|
|
102
|
+
);
|
|
103
|
+
// ensure machines eventually known
|
|
104
|
+
machinesP.catch(() => {});
|
|
105
|
+
dataP.catch(() => {});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!remembered) {
|
|
110
|
+
await machinesP;
|
|
111
|
+
const ms = machinesList.peek() || [];
|
|
112
|
+
if (!ms.length) {
|
|
113
|
+
showMachinePicker();
|
|
114
|
+
dataP.catch(() => {});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
showMachinePicker();
|
|
118
|
+
dataP.catch(() => {});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await dataP;
|
|
123
|
+
const bar = $('topbar-project');
|
|
124
|
+
if (bar && bar.textContent === 'Select a session') {
|
|
125
|
+
bar.textContent = `Machine · ${remembered}`;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Standalone: cache-first sessions + live model lists, then deep link
|
|
131
|
+
const dataP = Promise.all([
|
|
132
|
+
loadAllSessions({ waitFresh: false }),
|
|
133
|
+
loadWorkspaces({ waitFresh: false }),
|
|
134
|
+
loadSessionMetaMap().catch(() => {}),
|
|
135
|
+
loadAgentsMeta().catch(() => {}),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
if (deep?.id) {
|
|
139
|
+
// Apply cache if any, then resume without waiting for network sessions
|
|
140
|
+
await getCachedSessions().then(c => {
|
|
141
|
+
if (Array.isArray(c)) sessionsData.value = c;
|
|
142
|
+
else if (c && Array.isArray(c.sessions)) sessionsData.value = c.sessions;
|
|
143
|
+
}).catch(() => {});
|
|
144
|
+
const s = sessionsData.peek().find(x => x.sessionId === deep.id)
|
|
145
|
+
|| (last?.sessionId === deep.id ? last : null);
|
|
146
|
+
await resumeSession(
|
|
147
|
+
deep.id,
|
|
148
|
+
s?.cwd || last?.cwd || null,
|
|
149
|
+
s?.configDir || last?.configDir || null,
|
|
150
|
+
s?.agent || last?.agent || null,
|
|
151
|
+
null,
|
|
152
|
+
{ tab: deep.tab || 'chat' },
|
|
153
|
+
);
|
|
154
|
+
dataP.catch(() => {});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await dataP;
|
|
159
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// shell/dashboard.js — welcome calendar + stats
|
|
2
|
+
import { esc, $ } from '../lib.js';
|
|
3
|
+
import {
|
|
4
|
+
ctx, sessionsData, agentFilter, hubMode, selectedMachineId, sessionDisplayTitle,
|
|
5
|
+
} from '../state.js';
|
|
6
|
+
import { formatTime, agentBadge, shortPath, coerceTs } from './util.js';
|
|
7
|
+
|
|
8
|
+
export let calYear = new Date().getFullYear();
|
|
9
|
+
export let calMonth = new Date().getMonth(); // 0-11
|
|
10
|
+
export let selectedCalDate = null;
|
|
11
|
+
|
|
12
|
+
function asSessionArray(v) {
|
|
13
|
+
if (Array.isArray(v)) return v;
|
|
14
|
+
if (v && Array.isArray(v.results)) return v.results;
|
|
15
|
+
if (v && Array.isArray(v.sessions)) return v.sessions;
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sessions for home calendar/stats.
|
|
21
|
+
* Uses full session list (not sidebar 14d / 120-cap filters) so the month grid is complete.
|
|
22
|
+
* Still respects agent filter + hub machine selection.
|
|
23
|
+
*/
|
|
24
|
+
export function getDashboardSessions() {
|
|
25
|
+
let list = asSessionArray(sessionsData.peek());
|
|
26
|
+
const mid = selectedMachineId.peek();
|
|
27
|
+
if (hubMode.peek() && mid) {
|
|
28
|
+
list = list.filter(s => s && (!s.machineId || s.machineId === mid));
|
|
29
|
+
}
|
|
30
|
+
const aFilter = agentFilter.peek();
|
|
31
|
+
if (aFilter && aFilter !== 'all') {
|
|
32
|
+
list = list.filter(s => s && (s.agent || 'claude') === aFilter);
|
|
33
|
+
}
|
|
34
|
+
return list;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getLocalDateString(date) {
|
|
38
|
+
const y = date.getFullYear();
|
|
39
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
40
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
41
|
+
return `${y}-${m}-${d}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getMonthGrid(year, month) {
|
|
45
|
+
const cells = [];
|
|
46
|
+
const firstDay = new Date(year, month, 1);
|
|
47
|
+
const startDay = firstDay.getDay();
|
|
48
|
+
const lastDay = new Date(year, month + 1, 0);
|
|
49
|
+
const totalDays = lastDay.getDate();
|
|
50
|
+
const prevLastDay = new Date(year, month, 0).getDate();
|
|
51
|
+
|
|
52
|
+
for (let i = startDay - 1; i >= 0; i--) {
|
|
53
|
+
cells.push({
|
|
54
|
+
date: new Date(year, month - 1, prevLastDay - i),
|
|
55
|
+
isCurrentMonth: false
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (let i = 1; i <= totalDays; i++) {
|
|
60
|
+
cells.push({
|
|
61
|
+
date: new Date(year, month, i),
|
|
62
|
+
isCurrentMonth: true
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const remaining = 7 - (cells.length % 7);
|
|
67
|
+
if (remaining < 7) {
|
|
68
|
+
for (let i = 1; i <= remaining; i++) {
|
|
69
|
+
cells.push({
|
|
70
|
+
date: new Date(year, month + 1, i),
|
|
71
|
+
isCurrentMonth: false
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return cells;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getSessionsForDate(date, sessions) {
|
|
80
|
+
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
81
|
+
const end = start + 24 * 60 * 60 * 1000;
|
|
82
|
+
return sessions.filter(s => {
|
|
83
|
+
const ts = coerceTs(s.updatedAt);
|
|
84
|
+
return ts != null && ts >= start && ts < end;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatTokenCount(n) {
|
|
89
|
+
if (n == null || isNaN(n)) return '0';
|
|
90
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
91
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
|
92
|
+
return String(n);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function updateDashboard(sessions) {
|
|
96
|
+
const grid = $('calendar-grid');
|
|
97
|
+
if (!grid) return;
|
|
98
|
+
|
|
99
|
+
// Always normalize; callers may pass filteredSessions / cache shapes
|
|
100
|
+
sessions = asSessionArray(sessions != null ? sessions : getDashboardSessions());
|
|
101
|
+
|
|
102
|
+
const header = $('calendar-month-year');
|
|
103
|
+
if (header) {
|
|
104
|
+
const dummyDate = new Date(calYear, calMonth, 1);
|
|
105
|
+
header.textContent = dummyDate.toLocaleDateString([], { month: 'long', year: 'numeric' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Calculate total sessions, unique projects, and total tokens
|
|
109
|
+
const totalSessions = sessions.length;
|
|
110
|
+
const uniqueProjects = new Set(sessions.map(s => s.cwd).filter(Boolean)).size;
|
|
111
|
+
const totalTokens = sessions.reduce((acc, s) => acc + (Number(s.totalTokens) || 0), 0);
|
|
112
|
+
|
|
113
|
+
const tSessionsEl = $('stats-total-sessions');
|
|
114
|
+
const tProjectsEl = $('stats-total-projects');
|
|
115
|
+
const tTokensEl = $('stats-total-tokens');
|
|
116
|
+
|
|
117
|
+
if (tSessionsEl) tSessionsEl.textContent = String(totalSessions);
|
|
118
|
+
if (tProjectsEl) tProjectsEl.textContent = String(uniqueProjects);
|
|
119
|
+
if (tTokensEl) tTokensEl.textContent = formatTokenCount(totalTokens);
|
|
120
|
+
|
|
121
|
+
const cells = getMonthGrid(calYear, calMonth);
|
|
122
|
+
|
|
123
|
+
if (!selectedCalDate) {
|
|
124
|
+
selectedCalDate = getLocalDateString(new Date());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
grid.innerHTML = cells.map(cell => {
|
|
128
|
+
const day = cell.date;
|
|
129
|
+
const dateStr = getLocalDateString(day);
|
|
130
|
+
const daySessions = getSessionsForDate(day, sessions);
|
|
131
|
+
const count = daySessions.length;
|
|
132
|
+
const isToday = day.toDateString() === new Date().toDateString();
|
|
133
|
+
const isSelected = dateStr === selectedCalDate;
|
|
134
|
+
|
|
135
|
+
let bgClass = '';
|
|
136
|
+
let textClass = 'text-base-content/65';
|
|
137
|
+
let borderClass = 'border border-transparent';
|
|
138
|
+
let cursorClass = 'cursor-default';
|
|
139
|
+
let opacityClass = cell.isCurrentMonth ? '' : 'opacity-25';
|
|
140
|
+
|
|
141
|
+
if (count > 0) {
|
|
142
|
+
cursorClass = 'cursor-pointer';
|
|
143
|
+
if (count <= 2) {
|
|
144
|
+
bgClass = 'bg-primary/15';
|
|
145
|
+
textClass = 'text-primary font-bold';
|
|
146
|
+
borderClass = 'border border-primary/30';
|
|
147
|
+
} else if (count <= 5) {
|
|
148
|
+
bgClass = 'bg-primary/30';
|
|
149
|
+
textClass = 'text-primary font-bold';
|
|
150
|
+
borderClass = 'border border-primary/50';
|
|
151
|
+
} else {
|
|
152
|
+
bgClass = 'bg-primary/60';
|
|
153
|
+
textClass = 'text-primary-content font-bold';
|
|
154
|
+
borderClass = 'border border-primary/80';
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
bgClass = 'bg-base-300/25';
|
|
158
|
+
if (cell.isCurrentMonth) cursorClass = 'hover:bg-base-300/50';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isToday) {
|
|
162
|
+
borderClass = 'border-2 border-primary';
|
|
163
|
+
} else if (isSelected) {
|
|
164
|
+
borderClass = 'border-2 border-accent';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const monthLabel = day.getDate() === 1 ? `<span class="absolute top-0.5 left-1 text-[8px] opacity-60 uppercase">${day.toLocaleDateString([], {month:'short'})}</span>` : '';
|
|
168
|
+
|
|
169
|
+
return `
|
|
170
|
+
<div class="aspect-square rounded-xl flex flex-col items-center justify-center p-2 relative transition-all min-h-[50px] md:min-h-[60px] ${bgClass} ${textClass} ${borderClass} ${cursorClass} ${opacityClass}"
|
|
171
|
+
data-cal-date="${dateStr}" title="${day.toDateString()}: ${count} session(s)">
|
|
172
|
+
${monthLabel}
|
|
173
|
+
<span class="text-sm font-semibold">${day.getDate()}</span>
|
|
174
|
+
${count > 0 ? `<span class="text-[10px] mt-0.5 opacity-80 font-medium whitespace-nowrap">${count}s</span>` : ''}
|
|
175
|
+
</div>`;
|
|
176
|
+
}).join('');
|
|
177
|
+
|
|
178
|
+
showSelectedDaySessions(sessions);
|
|
179
|
+
updateRecentSessionsList(sessions);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function showSelectedDaySessions(sessions) {
|
|
183
|
+
const titleEl = $('selected-day-title');
|
|
184
|
+
const listEl = $('selected-day-sessions');
|
|
185
|
+
if (!titleEl || !listEl) return;
|
|
186
|
+
|
|
187
|
+
const [y, m, d] = selectedCalDate.split('-').map(Number);
|
|
188
|
+
const targetDate = new Date(y, m - 1, d);
|
|
189
|
+
titleEl.textContent = `Activity on ${targetDate.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}`;
|
|
190
|
+
|
|
191
|
+
sessions = asSessionArray(sessions);
|
|
192
|
+
const daySessions = getSessionsForDate(targetDate, sessions);
|
|
193
|
+
const selectedTokens = daySessions.reduce((acc, s) => acc + (Number(s.totalTokens) || 0), 0);
|
|
194
|
+
const sTokensEl = $('stats-selected-tokens');
|
|
195
|
+
if (sTokensEl) sTokensEl.textContent = formatTokenCount(selectedTokens);
|
|
196
|
+
|
|
197
|
+
if (!daySessions.length) {
|
|
198
|
+
listEl.innerHTML = `<div class="text-center text-xs text-base-content/40 my-auto py-6">No sessions active on this day</div>`;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
daySessions.sort((a, b) => (coerceTs(b.updatedAt) || 0) - (coerceTs(a.updatedAt) || 0));
|
|
203
|
+
|
|
204
|
+
listEl.innerHTML = daySessions.map(s => {
|
|
205
|
+
const ms = coerceTs(s.updatedAt);
|
|
206
|
+
const timeStr = ms != null
|
|
207
|
+
? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
208
|
+
: '';
|
|
209
|
+
const active = ctx.sessionId === s.sessionId;
|
|
210
|
+
return `
|
|
211
|
+
<div class="flex items-center gap-2.5 p-2.5 rounded-lg bg-base-300/40 hover:bg-base-300 border border-base-300/50 cursor-pointer text-left transition-all ${active ? 'border-primary bg-primary/5' : ''}"
|
|
212
|
+
data-session-id="${esc(s.sessionId)}"
|
|
213
|
+
data-session-cwd="${esc(s.cwd || '')}"
|
|
214
|
+
data-session-config-dir="${esc(s.configDir || '')}"
|
|
215
|
+
data-session-agent="${esc(s.agent || 'claude')}"
|
|
216
|
+
data-session-machine="${esc(s.machineId || '')}">
|
|
217
|
+
<div class="flex-shrink-0">${agentBadge(s.agent)}</div>
|
|
218
|
+
<div class="flex-1 min-w-0">
|
|
219
|
+
<div class="text-xs font-semibold truncate text-base-content/90">${esc(sessionDisplayTitle(s))}</div>
|
|
220
|
+
<div class="text-[10px] text-base-content/40 truncate mt-0.5">${esc(s.projectName || shortPath(s.cwd) || 'No workspace')}</div>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="text-[9px] text-base-content/45 font-mono whitespace-nowrap bg-base-300 px-1.5 py-0.5 rounded">${timeStr}</div>
|
|
223
|
+
</div>`;
|
|
224
|
+
}).join('');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function updateRecentSessionsList(sessions) {
|
|
228
|
+
const container = $('recent-sessions-list');
|
|
229
|
+
if (!container) return;
|
|
230
|
+
|
|
231
|
+
sessions = asSessionArray(sessions);
|
|
232
|
+
const recent = [...sessions]
|
|
233
|
+
.sort((a, b) => (coerceTs(b.updatedAt) || 0) - (coerceTs(a.updatedAt) || 0))
|
|
234
|
+
.slice(0, 6);
|
|
235
|
+
|
|
236
|
+
if (!recent.length) {
|
|
237
|
+
container.innerHTML = `<div class="col-span-full text-center text-xs text-base-content/40 py-4">No recent sessions</div>`;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
container.innerHTML = recent.map(s => {
|
|
242
|
+
const timeStr = formatTime(s.updatedAt);
|
|
243
|
+
const active = ctx.sessionId === s.sessionId;
|
|
244
|
+
return `
|
|
245
|
+
<div class="flex items-center gap-2.5 p-3 rounded-xl bg-base-200 hover:bg-base-300 border border-base-300/80 cursor-pointer text-left transition-all ${active ? 'border-primary bg-primary/5' : ''}"
|
|
246
|
+
data-session-id="${esc(s.sessionId)}"
|
|
247
|
+
data-session-cwd="${esc(s.cwd || '')}"
|
|
248
|
+
data-session-config-dir="${esc(s.configDir || '')}"
|
|
249
|
+
data-session-agent="${esc(s.agent || 'claude')}"
|
|
250
|
+
data-session-machine="${esc(s.machineId || '')}">
|
|
251
|
+
<div class="flex-shrink-0">${agentBadge(s.agent)}</div>
|
|
252
|
+
<div class="flex-1 min-w-0">
|
|
253
|
+
<div class="text-xs font-bold truncate text-base-content/95">${esc(sessionDisplayTitle(s))}</div>
|
|
254
|
+
<div class="text-[10px] text-base-content/40 truncate mt-1">${esc(s.projectName || shortPath(s.cwd) || 'No workspace')}</div>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="text-[9px] text-base-content/40 whitespace-nowrap self-start mt-0.5">${timeStr}</div>
|
|
257
|
+
</div>`;
|
|
258
|
+
}).join('');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function setCalMonthYear(m, y) { calMonth = m; calYear = y; }
|
|
262
|
+
export function setSelectedCalDate(d) { selectedCalDate = d; }
|
|
263
|
+
|
|
264
|
+
/** Mutate calendar month by delta months (imported bindings are read-only). */
|
|
265
|
+
export function shiftCalMonth(delta) {
|
|
266
|
+
calMonth += delta;
|
|
267
|
+
if (calMonth < 0) { calMonth = 11; calYear--; }
|
|
268
|
+
else if (calMonth > 11) { calMonth = 0; calYear++; }
|
|
269
|
+
}
|