spectoflow 0.24.0 → 0.27.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 +28 -6
- package/bin/spectoflow.js +88 -6
- package/lib/dashboard/connector.js +193 -0
- package/lib/dashboard/handlers.js +2 -27
- package/lib/dashboard/hub-server.js +103 -11
- package/lib/dashboard/inject-design.js +41 -0
- package/lib/dashboard/meeting.js +116 -0
- package/lib/dashboard/ops.js +83 -2
- package/lib/dashboard/public/app.js +738 -118
- package/lib/dashboard/public/charts.js +3 -3
- package/lib/dashboard/public/commands.js +77 -0
- package/lib/dashboard/public/designs/console.css +7 -19
- package/lib/dashboard/public/designs/orbit.css +3 -4
- package/lib/dashboard/public/designs.js +2 -2
- package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
- package/lib/dashboard/public/hub.html +1 -1
- package/lib/dashboard/public/hub.js +27 -4
- package/lib/dashboard/public/i18n.js +60 -18
- package/lib/dashboard/public/icons.js +2 -0
- package/lib/dashboard/public/index.html +106 -13
- package/lib/dashboard/public/styles.css +161 -26
- package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
- package/lib/dashboard/routes.js +38 -0
- package/lib/dashboard/runner.js +7 -2
- package/lib/workspace.js +36 -1
- package/package.json +3 -3
- package/templates/config.json +23 -0
- package/lib/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- package/lib/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- package/lib/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
|
@@ -6,14 +6,43 @@ const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To valida
|
|
|
6
6
|
function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('status.'+k); }
|
|
7
7
|
let P = null, openTaskId = null;
|
|
8
8
|
let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// boardView/sideHidden/expandedPhases/activeTab/chatOpen used to be per-viewer localStorage
|
|
10
|
+
// preferences; they are now real project settings, persisted server-side via /api/settings (same
|
|
11
|
+
// mechanism as design/mode/language/agent — see saveSetting() below) so they survive a reload on any
|
|
12
|
+
// device/browser, including through the online-hosted dashboard. Each starts at the same default the
|
|
13
|
+
// old localStorage-absent fallback used, then is reconciled from P.config the first time it loads
|
|
14
|
+
// (see syncSettingsFromServer(), called once from load()) — after that they're local state mutated
|
|
15
|
+
// only by user actions, exactly like the old localStorage-backed vars were.
|
|
16
|
+
// The 13 native tab ids, mirroring ops.js's NATIVE_TABS exactly — the client's own source of truth
|
|
17
|
+
// for "what's a real native tab id" (used to validate P.config.navTabs before trusting it, same
|
|
18
|
+
// defensive stance as ROUTES further down). Task 1 shipped the original 11, all default-enabled; Task
|
|
19
|
+
// 2 (Sous-projet C) registered 'notes' — the Bloc note scratchpad — as the first tab that must start
|
|
20
|
+
// OFF by default (an opt-in feature, per the user's explicit request); Task 3 registers 'meeting' —
|
|
21
|
+
// the Daily meeting tab — the same way, so DEFAULT_OFF_TABS exists rather than a blanket "every
|
|
22
|
+
// native tab defaults to enabled" the way Task 1 assumed.
|
|
23
|
+
const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'personalize'];
|
|
24
|
+
const DEFAULT_OFF_TABS = new Set(['notes', 'meeting']);
|
|
25
|
+
function defaultNavTabs() { return NATIVE_TABS.map((id) => ({ id, enabled: !DEFAULT_OFF_TABS.has(id) })); }
|
|
26
|
+
let boardView = 'list'; // 'list' | 'kanban'
|
|
27
|
+
let sideHidden = false; // right sidebar (Journal/Specs/Running) — mainly to give Kanban's own-width columns more room
|
|
28
|
+
// Kanban column visibility + per-column page size (Sous-projet B, Task 2) — same persisted-project-
|
|
29
|
+
// setting shape as the vars above. kanbanColumns defaults to every status (today's behavior); the
|
|
30
|
+
// server (writeConfig()) refuses to ever persist an empty array, and the client UI mirrors that same
|
|
31
|
+
// rule (see toggleKanbanColumn()) so the last visible column can't be turned off either way.
|
|
32
|
+
let kanbanColumns = Object.keys(STATUS);
|
|
33
|
+
let kanbanPageSize = 10; // 10 | 20
|
|
34
|
+
// Per-column "show more" reveal state — deliberately NOT persisted (a per-view convenience, not a
|
|
35
|
+
// saved preference): starts empty every load and is reset on any full render() tick (SSE reload) or
|
|
36
|
+
// on re-filtering (search) / changing the page size, but survives a "Show more" click itself (that
|
|
37
|
+
// handler adds to this set then re-renders the board without clearing it first).
|
|
38
|
+
let kanbanExpanded = new Set();
|
|
11
39
|
let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
|
|
12
40
|
let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
|
|
13
41
|
let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
|
|
14
42
|
let attnFilter = 'open'; // attention tab filter — client-side only
|
|
15
|
-
//
|
|
16
|
-
|
|
43
|
+
// The design skin itself is applied server-side, before the client even sees this file: hub-server.js
|
|
44
|
+
// (lib/dashboard/inject-design.js) stamps data-design="..." straight into the served index.html's
|
|
45
|
+
// <html> tag from the project's real config.design — no localStorage read needed for first paint.
|
|
17
46
|
|
|
18
47
|
// The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
|
|
19
48
|
// hub-server's legacy-route redirect (sub-project 3) guarantees a bookmark without this prefix never
|
|
@@ -31,6 +60,232 @@ function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : res
|
|
|
31
60
|
// strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
|
|
32
61
|
function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
|
|
33
62
|
|
|
63
|
+
// ---- online dashboard (C1): offline, read-only state ----
|
|
64
|
+
// Served by the relay while a project's machine is away, /api/project carries online:false + lastSeen.
|
|
65
|
+
// One guard on fetch() short-circuits every mutating /api call to a 503 (instead of touching ~40 call
|
|
66
|
+
// sites); the banner says why; the busy state (updateChatBusyUI) disables the run/orchestrate buttons.
|
|
67
|
+
// The local hub never sends online:false, so none of this ever triggers locally.
|
|
68
|
+
let OFFLINE=false;
|
|
69
|
+
const _fetch=window.fetch.bind(window);
|
|
70
|
+
window.fetch=(url,opts)=>{
|
|
71
|
+
const method=((opts&&opts.method)||'GET').toUpperCase();
|
|
72
|
+
if(OFFLINE&&method!=='GET'&&String(url).startsWith('/api/')) return Promise.resolve(new Response(JSON.stringify({error:t('offline.readonly')}),{status:503,headers:{'Content-Type':'application/json'}}));
|
|
73
|
+
return _fetch(url,opts);
|
|
74
|
+
};
|
|
75
|
+
function setOffline(p){
|
|
76
|
+
OFFLINE=p.online===false;
|
|
77
|
+
document.body.classList.toggle('is-offline',OFFLINE);
|
|
78
|
+
const bar=$('#offlineBar'); if(!bar) return;
|
|
79
|
+
bar.hidden=!OFFLINE;
|
|
80
|
+
if(OFFLINE){ const when=p.lastSeen?new Date(p.lastSeen).toLocaleString():'—'; $('#offlineText').textContent=t('offline.banner',{name:p.projectName||'project',when}); }
|
|
81
|
+
updateChatBusyUI();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- generalized per-project settings persistence (Sous-projet B) ----
|
|
85
|
+
// Every dashboard preference below (theme/boardView/sideHidden/expandedPhases/activeTab/chatOpen) is
|
|
86
|
+
// now a real config.json field, saved through the exact same /api/settings op used by design/mode/
|
|
87
|
+
// language/agent — see docs' Sous-projet B note. A frequent-write setting (activeTab, chatOpen,
|
|
88
|
+
// expandedPhases can change on nearly every click) is debounced so a burst of clicks collapses into
|
|
89
|
+
// one write/round-trip; the visible UI/DOM always updates synchronously and instantly regardless —
|
|
90
|
+
// only the persistence call is delayed. Reused for the rarer settings too (design/theme/boardView/
|
|
91
|
+
// sideHidden) since a 300ms delay on one isolated click is imperceptible and it keeps one code path.
|
|
92
|
+
let _saveSettingTimer = null, _saveSettingPending = null;
|
|
93
|
+
function saveSetting(patch) {
|
|
94
|
+
_saveSettingPending = Object.assign(_saveSettingPending || {}, patch);
|
|
95
|
+
clearTimeout(_saveSettingTimer);
|
|
96
|
+
_saveSettingTimer = setTimeout(() => {
|
|
97
|
+
const body = _saveSettingPending; _saveSettingPending = null;
|
|
98
|
+
fetch(withProject('/api/settings'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).catch(() => {});
|
|
99
|
+
}, 400);
|
|
100
|
+
}
|
|
101
|
+
// Reconciles the local, session-scoped preference vars from the server's config the first time a
|
|
102
|
+
// project's data loads — after that they're local state, mutated only by user actions (exactly like
|
|
103
|
+
// the old localStorage-backed vars were), and saved back out via saveSetting() above. Only ever
|
|
104
|
+
// applied once (settingsSynced) so a later reload (SSE-driven, e.g. another viewer editing a task)
|
|
105
|
+
// never clobbers a change the user just made locally that hasn't finished its debounce yet.
|
|
106
|
+
let settingsSynced = false;
|
|
107
|
+
function syncSettingsFromServer() {
|
|
108
|
+
if (settingsSynced || !P || !P.config) return;
|
|
109
|
+
settingsSynced = true;
|
|
110
|
+
const c = P.config;
|
|
111
|
+
if (typeof c.theme === 'string') document.documentElement.setAttribute('data-theme', c.theme);
|
|
112
|
+
if (typeof c.boardView === 'string') boardView = c.boardView;
|
|
113
|
+
if (typeof c.sideHidden === 'boolean') { sideHidden = c.sideHidden; applySideHidden(); }
|
|
114
|
+
if (Array.isArray(c.expandedPhases)) { expandedPhases = new Set(c.expandedPhases); updatePhaseToggleAll(); }
|
|
115
|
+
// activeTab: a URL deep-link (tabFromPath()) always wins over the saved preference — matches the
|
|
116
|
+
// old localStorage priority order exactly (tabFromPath() || saved || 'board').
|
|
117
|
+
if (typeof c.activeTab === 'string' && !tabFromPath()) { activeTab = normalizeTab(c.activeTab); applyActiveTab(); }
|
|
118
|
+
if (typeof c.chatOpen === 'boolean' && c.chatOpen) setChat(true);
|
|
119
|
+
if (Array.isArray(c.kanbanColumns) && c.kanbanColumns.length) kanbanColumns = c.kanbanColumns.filter((k) => STATUS[k]);
|
|
120
|
+
if (c.kanbanPageSize === 10 || c.kanbanPageSize === 20) kanbanPageSize = c.kanbanPageSize;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- nav tabs enable/disable/reorder (Sous-projet C, Task 1) ----------------------------------
|
|
124
|
+
// Unlike the per-viewer preferences above, navTabs is read fresh from P.config on every render() —
|
|
125
|
+
// it's shared, structural nav config (closer to P.customDashboards than to a per-viewer convenience
|
|
126
|
+
// like boardView), so another viewer/session disabling or reordering a tab must take effect on this
|
|
127
|
+
// tab's very next SSE-driven reload, not just after this tab's own next full page load. A local
|
|
128
|
+
// optimistic edit (toggleNavTab()/moveNavTab() below) mutates P.config.navTabs directly so it's
|
|
129
|
+
// still reflected instantly and survives until the debounced save round-trips.
|
|
130
|
+
function currentNavTabs() {
|
|
131
|
+
const c = P && P.config, arr = c && c.navTabs;
|
|
132
|
+
// A config saved before a new native tab existed (e.g. any project's navTabs from before this task
|
|
133
|
+
// added 'notes' — including one with no navTabs field at all, like a project that predates Task 1)
|
|
134
|
+
// is a genuinely common case, not just a malformed one: keep whatever known, non-duplicate entries
|
|
135
|
+
// it already has (preserving the viewer's own enable-state/order) and only append the ids it's
|
|
136
|
+
// missing, each at ITS OWN default (see DEFAULT_OFF_TABS) — never reset the whole array back to
|
|
137
|
+
// defaults just because it's short one id. A truly invalid entry (unknown id, duplicate, wrong
|
|
138
|
+
// shape) is dropped rather than trusted.
|
|
139
|
+
const known = Array.isArray(arr)
|
|
140
|
+
? arr.filter((v) => v && typeof v === 'object' && typeof v.id === 'string' && typeof v.enabled === 'boolean' && NATIVE_TABS.includes(v.id))
|
|
141
|
+
: [];
|
|
142
|
+
const noDupes = new Set(known.map((v) => v.id)).size === known.length;
|
|
143
|
+
const base = noDupes ? known.slice() : [];
|
|
144
|
+
NATIVE_TABS.forEach((id) => { if (!base.find((v) => v.id === id)) base.push({ id, enabled: !DEFAULT_OFF_TABS.has(id) }); });
|
|
145
|
+
return base.map((v) => ({ id: v.id, enabled: v.id === 'personalize' ? true : !!v.enabled }));
|
|
146
|
+
}
|
|
147
|
+
// Hides/reorders the native <button class="tab"> elements inside #tabs to match currentNavTabs() —
|
|
148
|
+
// Console's rail and Orbit's radial menu both read #tabs live (see syncCustomTabs()'s own comment),
|
|
149
|
+
// so operating on the shared DOM here covers every design with no per-design code. Custom-dashboard
|
|
150
|
+
// tabs (syncCustomTabs()) are never touched: each native button is inserted right before whichever
|
|
151
|
+
// tab was already the first "custom:" one (or appended at the end if there isn't one yet), so native
|
|
152
|
+
// tabs always land before custom ones regardless of the order these two functions run in.
|
|
153
|
+
function applyNavTabs() {
|
|
154
|
+
const nav = $('#tabs'); if (!nav) return;
|
|
155
|
+
const list = currentNavTabs();
|
|
156
|
+
const anchor = nav.querySelector('.tab[data-tab^="custom:"]') || null;
|
|
157
|
+
list.forEach((entry) => {
|
|
158
|
+
const btn = nav.querySelector('.tab[data-tab="' + entry.id + '"]');
|
|
159
|
+
if (!btn) return;
|
|
160
|
+
btn.hidden = !entry.enabled;
|
|
161
|
+
nav.insertBefore(btn, anchor);
|
|
162
|
+
});
|
|
163
|
+
// If the currently active NATIVE tab just became disabled (e.g. the user disabled the tab they're
|
|
164
|
+
// viewing, or another viewer did and this tab just reloaded), navigate to a sensible fallback
|
|
165
|
+
// instead of leaving a hidden/disabled panel showing.
|
|
166
|
+
const activeEntry = list.find((e) => e.id === activeTab);
|
|
167
|
+
if (activeEntry && !activeEntry.enabled) {
|
|
168
|
+
const fallback = list.find((e) => e.id === 'board' && e.enabled) || list.find((e) => e.enabled);
|
|
169
|
+
if (fallback) navigateTab(fallback.id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Toggles one tab's enabled state (locked no-op for 'personalize' — mirrors the server's own refusal
|
|
173
|
+
// so a click there gives immediate, obvious feedback instead of a silently-ignored save) and applies
|
|
174
|
+
// it live, then persists the whole array via saveSetting() (the "whole array" contract writeConfig()
|
|
175
|
+
// expects, same as toggleKanbanColumn()'s kanbanColumns save).
|
|
176
|
+
function toggleNavTab(id) {
|
|
177
|
+
if (id === 'personalize') return;
|
|
178
|
+
const next = currentNavTabs().map((e) => e.id === id ? { id, enabled: !e.enabled } : e);
|
|
179
|
+
if (P && P.config) P.config.navTabs = next;
|
|
180
|
+
applyNavTabs(); renderNavTabsSettings(); saveSetting({ navTabs: next });
|
|
181
|
+
}
|
|
182
|
+
// Moves one tab up/down in the order (no-op at either boundary — mirrored by disabling the button in
|
|
183
|
+
// renderNavTabsSettings()) and applies + persists exactly like toggleNavTab().
|
|
184
|
+
function moveNavTab(id, dir) {
|
|
185
|
+
const list = currentNavTabs();
|
|
186
|
+
const i = list.findIndex((e) => e.id === id);
|
|
187
|
+
const j = i + dir;
|
|
188
|
+
if (i < 0 || j < 0 || j >= list.length) return;
|
|
189
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
190
|
+
if (P && P.config) P.config.navTabs = list;
|
|
191
|
+
applyNavTabs(); renderNavTabsSettings(); saveSetting({ navTabs: list });
|
|
192
|
+
}
|
|
193
|
+
// Renders the Personalize → "Navigation tabs" card: one row per native tab, in its own order — a
|
|
194
|
+
// checkbox (disabled + explained for 'personalize', which can never be turned off) and up/down
|
|
195
|
+
// reorder buttons (disabled at either boundary). Reuses each tab's own nav.* i18n label — no new tab
|
|
196
|
+
// names invented here.
|
|
197
|
+
function renderNavTabsSettings() {
|
|
198
|
+
const box = $('#navTabsList'); if (!box) return;
|
|
199
|
+
const list = currentNavTabs();
|
|
200
|
+
box.innerHTML = '';
|
|
201
|
+
list.forEach((entry, idx) => {
|
|
202
|
+
const locked = entry.id === 'personalize';
|
|
203
|
+
const row = el('div', 'nav-tabs-row' + (locked ? ' is-locked' : ''));
|
|
204
|
+
const cb = document.createElement('input');
|
|
205
|
+
cb.type = 'checkbox'; cb.checked = entry.enabled; cb.disabled = locked;
|
|
206
|
+
cb.setAttribute('aria-label', t('nav.' + (entry.id === 'personalize' ? 'settings' : entry.id)));
|
|
207
|
+
cb.addEventListener('change', () => toggleNavTab(entry.id));
|
|
208
|
+
const label = el('span', 'nav-tabs-label', t('nav.' + (entry.id === 'personalize' ? 'settings' : entry.id)));
|
|
209
|
+
if (locked) label.append(el('span', 'nav-tabs-lock-note', t('settings.navTabs.locked')));
|
|
210
|
+
const move = el('div', 'nav-tabs-move');
|
|
211
|
+
const up = el('button', null, '↑'); up.type = 'button'; up.title = t('settings.navTabs.moveUp'); up.setAttribute('aria-label', t('settings.navTabs.moveUp'));
|
|
212
|
+
up.disabled = idx === 0; up.addEventListener('click', () => moveNavTab(entry.id, -1));
|
|
213
|
+
const down = el('button', null, '↓'); down.type = 'button'; down.title = t('settings.navTabs.moveDown'); down.setAttribute('aria-label', t('settings.navTabs.moveDown'));
|
|
214
|
+
down.disabled = idx === list.length - 1; down.addEventListener('click', () => moveNavTab(entry.id, 1));
|
|
215
|
+
move.append(up, down);
|
|
216
|
+
row.append(cb, label, move);
|
|
217
|
+
box.append(row);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Personalize → "Commands" card. Lists the effective commands (built-ins until the user edits, then
|
|
222
|
+
// the persisted config.commands); each row toggles enabled, or opens an inline edit form. Add opens a
|
|
223
|
+
// blank form. All mutations write the WHOLE array via saveSetting({commands}) — the contract
|
|
224
|
+
// writeConfig() expects — mirroring toggleNavTab()/toggleKanbanColumn(). No native prompt/confirm
|
|
225
|
+
// (they block SSE): delete is a direct button (matches renderNavTabsSettings, which gates nothing on
|
|
226
|
+
// relay read-only either — there is no `P.config.readonly`/offline gating on this card, by design).
|
|
227
|
+
let _cmdEditIndex = null; // index being edited, -1 = adding, null = form closed
|
|
228
|
+
function currentCommands() { return SpectoCommands.effectiveCommands((P && P.config) || {}).map((c) => ({ trigger: c.trigger, description: c.description || '', instruction: c.instruction || '', enabled: c.enabled !== false })); }
|
|
229
|
+
function saveCommands(list) { if (P && P.config) P.config.commands = list; renderCommandsSettings(); saveSetting({ commands: list }); }
|
|
230
|
+
// Any list mutation other than the form's own submit closes an open edit form first — an index
|
|
231
|
+
// captured when the form opened can otherwise point past the end of the list, or at a DIFFERENT
|
|
232
|
+
// entry, once rows are added/removed/reordered underneath it (see renderCommandsSettings's own
|
|
233
|
+
// defensive guard below for the belt-and-suspenders half of this fix).
|
|
234
|
+
function toggleCommand(i) { _cmdEditIndex = null; const list = currentCommands(); if (!list[i]) return; list[i].enabled = !list[i].enabled; saveCommands(list); }
|
|
235
|
+
function deleteCommand(i) { _cmdEditIndex = null; const list = currentCommands(); list.splice(i, 1); saveCommands(list); }
|
|
236
|
+
function restoreCommands() { _cmdEditIndex = null; saveCommands(SpectoCommands.BUILTIN_COMMANDS.map((c) => ({ ...c }))); }
|
|
237
|
+
function openCmdForm(i) { _cmdEditIndex = i; renderCommandsSettings(); }
|
|
238
|
+
function closeCmdForm() { _cmdEditIndex = null; renderCommandsSettings(); }
|
|
239
|
+
function submitCmdForm() {
|
|
240
|
+
const trg = $('#cmdFormTrigger').value.trim().toLowerCase();
|
|
241
|
+
const desc = $('#cmdFormDesc').value.trim();
|
|
242
|
+
const instr = $('#cmdFormInstr').value.trim();
|
|
243
|
+
const errEl = $('#cmdFormErr'); errEl.textContent = '';
|
|
244
|
+
const list = currentCommands();
|
|
245
|
+
const others = list.filter((_, idx) => idx !== _cmdEditIndex).map((c) => c.trigger);
|
|
246
|
+
const v = SpectoCommands.validateTrigger(trg, others);
|
|
247
|
+
if (!v.ok) { errEl.textContent = t('settings.commands.err.' + v.error); return; }
|
|
248
|
+
if (!instr) { errEl.textContent = t('settings.commands.err.emptyInstruction'); return; }
|
|
249
|
+
const entry = { trigger: trg, description: desc, instruction: instr, enabled: _cmdEditIndex >= 0 ? list[_cmdEditIndex].enabled : true };
|
|
250
|
+
if (_cmdEditIndex >= 0) list[_cmdEditIndex] = entry; else list.push(entry);
|
|
251
|
+
_cmdEditIndex = null; saveCommands(list);
|
|
252
|
+
}
|
|
253
|
+
function renderCommandsSettings() {
|
|
254
|
+
const box = $('#commandsList'); if (!box) return;
|
|
255
|
+
box.innerHTML = '';
|
|
256
|
+
const list = currentCommands();
|
|
257
|
+
list.forEach((c, i) => {
|
|
258
|
+
const row = el('div', 'cmd-row');
|
|
259
|
+
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = c.enabled;
|
|
260
|
+
cb.setAttribute('aria-label', c.trigger); cb.addEventListener('change', () => toggleCommand(i));
|
|
261
|
+
const lab = el('span', 'cmd-row-label'); lab.append(el('span', 'cmd-trigger', '/' + c.trigger));
|
|
262
|
+
if (c.description) lab.append(el('span', 'cmd-desc', c.description));
|
|
263
|
+
const acts = el('div', 'cmd-row-acts');
|
|
264
|
+
const edit = el('button', 'btn', t('action.edit')); edit.type = 'button'; edit.addEventListener('click', () => openCmdForm(i));
|
|
265
|
+
const del = el('button', 'btn danger', t('action.delete')); del.type = 'button'; del.addEventListener('click', () => deleteCommand(i));
|
|
266
|
+
acts.append(edit, del); row.append(cb, lab, acts); box.append(row);
|
|
267
|
+
});
|
|
268
|
+
if (_cmdEditIndex !== null) {
|
|
269
|
+
// Defensive: a stale index (from a mutation that somehow didn't already reset it) must never
|
|
270
|
+
// throw here — fall back to a blank form rather than crashing render() mid-tick.
|
|
271
|
+
const editing = (_cmdEditIndex >= 0 && list[_cmdEditIndex]) ? list[_cmdEditIndex] : { trigger: '', description: '', instruction: '' };
|
|
272
|
+
const form = el('div', 'cmd-form');
|
|
273
|
+
const mk = (id, ph, val, tag) => { const f = document.createElement(tag || 'input'); f.id = id; f.placeholder = ph; f.value = val || ''; return f; };
|
|
274
|
+
const tr = mk('cmdFormTrigger', t('settings.commands.ph.trigger'), editing.trigger);
|
|
275
|
+
const de = mk('cmdFormDesc', t('settings.commands.ph.desc'), editing.description);
|
|
276
|
+
const ins = mk('cmdFormInstr', t('settings.commands.ph.instruction'), editing.instruction, 'textarea'); ins.className = 'chat-ta';
|
|
277
|
+
const err = el('div', 'cmd-error'); err.id = 'cmdFormErr';
|
|
278
|
+
const save = el('button', 'btn primary', t('action.save')); save.type = 'button'; save.addEventListener('click', submitCmdForm);
|
|
279
|
+
const cancel = el('button', 'btn', t('action.cancel')); cancel.type = 'button'; cancel.addEventListener('click', closeCmdForm);
|
|
280
|
+
const btns = el('div', 'cmd-form-btns'); btns.append(save, cancel);
|
|
281
|
+
form.append(el('label', null, t('settings.commands.ph.trigger')), tr, el('label', null, t('settings.commands.ph.desc')), de, el('label', null, t('settings.commands.ph.instruction')), ins, err, btns);
|
|
282
|
+
box.append(form);
|
|
283
|
+
}
|
|
284
|
+
const addBtn = $('#cmdAddBtn'), resBtn = $('#cmdRestoreBtn');
|
|
285
|
+
if (addBtn) addBtn.onclick = () => openCmdForm(-1);
|
|
286
|
+
if (resBtn) resBtn.onclick = restoreCommands;
|
|
287
|
+
}
|
|
288
|
+
|
|
34
289
|
const $ = (s,r=document)=>r.querySelector(s);
|
|
35
290
|
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
|
|
36
291
|
const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
|
|
@@ -38,7 +293,9 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
|
|
|
38
293
|
const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
|
|
39
294
|
|
|
40
295
|
async function load(){
|
|
41
|
-
const r = await fetch(withProject('/api/project')); P = await r.json();
|
|
296
|
+
const r = await fetch(withProject('/api/project')); P = await r.json();
|
|
297
|
+
syncSettingsFromServer();
|
|
298
|
+
render(); setOffline(P);
|
|
42
299
|
if(openTaskId) openDrawer(openTaskId,true);
|
|
43
300
|
}
|
|
44
301
|
// Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
|
|
@@ -121,10 +378,12 @@ function appendRaw(chunk){
|
|
|
121
378
|
scrollChat(container);
|
|
122
379
|
});
|
|
123
380
|
}
|
|
381
|
+
// DOM-only — does not persist. Called both by real user toggles (which persist separately, see the
|
|
382
|
+
// chatFab/chatClose/runQuickBtn listeners below) and by syncSettingsFromServer() reflecting a saved
|
|
383
|
+
// chatOpen back onto a fresh page load, which must never re-trigger a save of the very value it read.
|
|
124
384
|
function setChat(open){
|
|
125
385
|
$('#chat').setAttribute('aria-hidden', open?'false':'true');
|
|
126
386
|
$('#chatFab').classList.toggle('is-open',open);
|
|
127
|
-
try{ localStorage.setItem('spf-chat', open?'1':'0'); }catch{}
|
|
128
387
|
// Land where you can actually type, not wherever the log happened to be scrolled last.
|
|
129
388
|
if(open) setTimeout(()=>{ scrollChat($('#chatLog')); $('#runPrompt').focus(); },60);
|
|
130
389
|
}
|
|
@@ -134,20 +393,86 @@ function setChat(open){
|
|
|
134
393
|
// isChatBusy() also guards the Ctrl/Cmd+Enter keyboard shortcuts, which call doRun() directly and
|
|
135
394
|
// would otherwise bypass the buttons' own disabled state.
|
|
136
395
|
function isChatBusy(){ return sseBusy || (P&&P.runtime&&P.runtime.orchestration&&P.runtime.orchestration.status==='running'); }
|
|
396
|
+
// If `raw` is a slash-command invocation of a known enabled command, return the expanded
|
|
397
|
+
// { prompt, display }; otherwise null (the raw text is sent unchanged). Uses the live config.
|
|
398
|
+
function expandForSend(raw){
|
|
399
|
+
try { return SpectoCommands.expandCommand(raw, SpectoCommands.effectiveCommands((P&&P.config)||{})); }
|
|
400
|
+
catch(_){ return null; }
|
|
401
|
+
}
|
|
137
402
|
async function doRun(promptEl,agentEl){
|
|
138
403
|
if(isChatBusy()) return;
|
|
139
404
|
promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
|
|
140
|
-
const
|
|
405
|
+
const raw=promptEl.value.trim(); if(!raw) return;
|
|
141
406
|
const agent=agentEl.value;
|
|
142
|
-
|
|
143
|
-
|
|
407
|
+
const ex=expandForSend(raw);
|
|
408
|
+
const body=ex ? {prompt:ex.prompt, display:ex.display, agent} : {prompt:raw, agent};
|
|
409
|
+
await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
410
|
+
promptEl.value=''; hideCmdMenu(); // the prompt renders as a bubble from the message log
|
|
144
411
|
}
|
|
145
412
|
async function doOrchestrate(promptEl){
|
|
146
413
|
if(isChatBusy()) return;
|
|
147
414
|
promptEl=promptEl||$('#runPrompt');
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
415
|
+
const raw=promptEl.value.trim(); if(!raw) return;
|
|
416
|
+
const ex=expandForSend(raw);
|
|
417
|
+
await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request: ex?ex.prompt:raw})});
|
|
418
|
+
promptEl.value=''; hideCmdMenu();
|
|
419
|
+
}
|
|
420
|
+
// ---- slash-command autocomplete: a single reused #cmdMenu popover, anchored above whichever chat
|
|
421
|
+
// textarea is active. Shows while the input is exactly "/word" (no space yet); hides once args begin.
|
|
422
|
+
// Keyboard (menu open only): ↑/↓ move, Enter/Tab select, Esc close — plain Enter in a textarea is a
|
|
423
|
+
// newline (send is Ctrl/Cmd+Enter), so intercepting it here doesn't change send behavior. ----
|
|
424
|
+
let _cmdMenuInput=null, _cmdMenuItems=[], _cmdMenuSel=-1;
|
|
425
|
+
function cmdMenuEl(){ return $('#cmdMenu'); }
|
|
426
|
+
function hideCmdMenu(){ const m=cmdMenuEl(); if(m){ m.hidden=true; m.innerHTML=''; } _cmdMenuInput=null; _cmdMenuItems=[]; _cmdMenuSel=-1; }
|
|
427
|
+
function slashQuery(v){ const m=String(v||'').match(/^\/([a-z0-9_-]*)$/i); return m?m[1]:null; }
|
|
428
|
+
function openCmdMenu(inputEl){
|
|
429
|
+
const q=slashQuery(inputEl.value);
|
|
430
|
+
if(q===null){ hideCmdMenu(); return; }
|
|
431
|
+
const cmds=SpectoCommands.matchCommands(q, SpectoCommands.effectiveCommands((P&&P.config)||{}));
|
|
432
|
+
const m=cmdMenuEl(); if(!m) return;
|
|
433
|
+
_cmdMenuInput=inputEl; _cmdMenuItems=cmds; _cmdMenuSel=cmds.length?0:-1;
|
|
434
|
+
m.innerHTML='';
|
|
435
|
+
if(!cmds.length){ m.append(el('div','cmd-empty', t('commands.menuEmpty'))); }
|
|
436
|
+
else cmds.forEach((c,i)=>{
|
|
437
|
+
const it=el('div','cmd-item'+(i===0?' is-sel':'')); it.setAttribute('role','option'); it.dataset.i=String(i);
|
|
438
|
+
it.append(el('span','cmd-trigger','/'+c.trigger));
|
|
439
|
+
if(c.description) it.append(el('span','cmd-desc',c.description));
|
|
440
|
+
it.addEventListener('mousedown',(e)=>{ e.preventDefault(); pickCmd(i); });
|
|
441
|
+
m.append(it);
|
|
442
|
+
});
|
|
443
|
+
m.hidden=false; positionCmdMenu(inputEl);
|
|
444
|
+
}
|
|
445
|
+
function positionCmdMenu(inputEl){
|
|
446
|
+
const m=cmdMenuEl(); if(!m||m.hidden) return;
|
|
447
|
+
const r=inputEl.getBoundingClientRect();
|
|
448
|
+
m.style.left=Math.round(r.left)+'px';
|
|
449
|
+
m.style.width=Math.round(r.width)+'px';
|
|
450
|
+
// place above the input; if it would clip the top, place below instead
|
|
451
|
+
const h=m.offsetHeight||160;
|
|
452
|
+
const top = r.top-h-6 >= 8 ? r.top-h-6 : r.bottom+6;
|
|
453
|
+
m.style.top=Math.round(top)+'px';
|
|
454
|
+
}
|
|
455
|
+
function moveCmdSel(d){
|
|
456
|
+
if(!_cmdMenuItems.length) return;
|
|
457
|
+
_cmdMenuSel=(_cmdMenuSel+d+_cmdMenuItems.length)%_cmdMenuItems.length;
|
|
458
|
+
$$('.cmd-item',cmdMenuEl()).forEach((n,i)=>n.classList.toggle('is-sel',i===_cmdMenuSel));
|
|
459
|
+
}
|
|
460
|
+
function pickCmd(i){
|
|
461
|
+
const c=_cmdMenuItems[i]; const inp=_cmdMenuInput;
|
|
462
|
+
if(!c||!inp) { hideCmdMenu(); return; }
|
|
463
|
+
inp.value='/'+c.trigger+' ';
|
|
464
|
+
hideCmdMenu();
|
|
465
|
+
inp.focus(); inp.selectionStart=inp.selectionEnd=inp.value.length;
|
|
466
|
+
}
|
|
467
|
+
// Returns true if it handled the key (caller should preventDefault + stop).
|
|
468
|
+
function cmdMenuKey(e){
|
|
469
|
+
const m=cmdMenuEl(); if(!m||m.hidden) return false;
|
|
470
|
+
if((e.metaKey||e.ctrlKey)&&e.key==='Enter') return false; // let the send shortcut through untouched
|
|
471
|
+
if(e.key==='ArrowDown'){ if(_cmdMenuItems.length){ moveCmdSel(1); return true; } return false; }
|
|
472
|
+
if(e.key==='ArrowUp'){ if(_cmdMenuItems.length){ moveCmdSel(-1); return true; } return false; }
|
|
473
|
+
if(e.key==='Enter'||e.key==='Tab'){ if(_cmdMenuSel>=0){ pickCmd(_cmdMenuSel); return true; } }
|
|
474
|
+
if(e.key==='Escape'){ hideCmdMenu(); return true; }
|
|
475
|
+
return false;
|
|
151
476
|
}
|
|
152
477
|
async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
|
|
153
478
|
// ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
|
|
@@ -177,11 +502,15 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
|
|
|
177
502
|
let sseBusy=false;
|
|
178
503
|
function updateChatBusyUI(){
|
|
179
504
|
const orchStatus=P&&P.runtime&&P.runtime.orchestration&&P.runtime.orchestration.status;
|
|
180
|
-
const busy=sseBusy||orchStatus==='running';
|
|
505
|
+
const busy=sseBusy||orchStatus==='running'||OFFLINE;
|
|
181
506
|
document.body.classList.toggle('chat-busy',!!busy);
|
|
182
|
-
|
|
507
|
+
// #meetingGenBtn/#meetingRunStatus (Sous-projet C, Task 3) ride the exact same busy signal — this
|
|
508
|
+
// is a single local agent process, so a meeting Generate run must disable Send/Orchestrate/
|
|
509
|
+
// Summarize too, and vice versa (only one agent run is ever in flight at once).
|
|
510
|
+
[$('#runBtn'),$('#orchBtn'),$('#widgetSummarizeBtn'),$('#tabRunBtn'),$('#tabOrchBtn'),$('#tabSummarizeBtn'),$('#meetingGenBtn')].forEach(b=>{ if(b) b.disabled=!!busy; });
|
|
183
511
|
const tabStatus=$('#tabChatStatus'); if(tabStatus) tabStatus.hidden=!busy;
|
|
184
512
|
const widgetStatus=$('#widgetChatStatus'); if(widgetStatus) widgetStatus.hidden=!busy;
|
|
513
|
+
const meetingStatus=$('#meetingRunStatus'); if(meetingStatus) meetingStatus.hidden=!busy;
|
|
185
514
|
}
|
|
186
515
|
|
|
187
516
|
function render(){
|
|
@@ -202,10 +531,12 @@ function render(){
|
|
|
202
531
|
if(meterFill) meterFill.style.width=(s.pct||0)+'%';
|
|
203
532
|
const meter=$('#globalMeter');
|
|
204
533
|
if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
|
|
534
|
+
kanbanExpanded.clear(); // a full SSE-driven reload always collapses any "show more" a viewer had open
|
|
205
535
|
renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
|
|
206
536
|
renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
|
|
207
|
-
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden(); updateChatBusyUI();
|
|
537
|
+
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); renderNotes(); renderMeeting(); applySideHidden(); updateChatBusyUI();
|
|
208
538
|
renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
|
|
539
|
+
applyNavTabs(); // hide/reorder native tabs per config.navTabs (may itself navigate away from a just-disabled active tab)
|
|
209
540
|
applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
|
|
210
541
|
applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
|
|
211
542
|
}
|
|
@@ -418,7 +749,8 @@ function renderBoard(){
|
|
|
418
749
|
running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
|
|
419
750
|
|
|
420
751
|
const board=$('#board'); board.innerHTML='';
|
|
421
|
-
updateBoardViewToggle();
|
|
752
|
+
updateBoardViewToggle(); updateKanbanPageSizeToggle();
|
|
753
|
+
if(kanbanColsOpen) renderKanbanColsPop(); // keep an open popover's checkbox state fresh across a live reload
|
|
422
754
|
board.classList.toggle('is-kanban', boardView==='kanban');
|
|
423
755
|
if(!tasks.length){ board.append(emptyState()); return; }
|
|
424
756
|
if(boardView==='kanban'){ renderKanban(board, tasks); return; } // columns by status
|
|
@@ -432,12 +764,15 @@ function renderBoard(){
|
|
|
432
764
|
if(!shown) board.append(noMatchState());
|
|
433
765
|
updatePhaseToggleAll();
|
|
434
766
|
}
|
|
435
|
-
// Kanban view — one column per status, filtered by the text search (columns already are the
|
|
767
|
+
// Kanban view — one column per status, filtered by the text search (columns already are the
|
|
768
|
+
// statuses). Only enabled columns (kanbanColumns) render at all; each column shows up to
|
|
769
|
+
// kanbanPageSize tasks (of its own filtered list) with a "Show more" reveal for the rest —
|
|
770
|
+
// see kanbanExpanded above for the reset rules.
|
|
436
771
|
function renderKanban(board, tasks){
|
|
437
772
|
const q=filter.q.trim().toLowerCase();
|
|
438
773
|
const match=(t)=> !q || (t.title+' '+t.id).toLowerCase().includes(q);
|
|
439
774
|
const cols=el('div','kanban');
|
|
440
|
-
Object.keys(STATUS).forEach(st=>{
|
|
775
|
+
Object.keys(STATUS).filter(st=> kanbanColumns.includes(st)).forEach(st=>{
|
|
441
776
|
const colTasks=tasks.filter(t=> t.status===st && match(t));
|
|
442
777
|
const col=el('div','kanban-col');
|
|
443
778
|
const head=el('div','kanban-col-head');
|
|
@@ -446,12 +781,23 @@ function renderKanban(board, tasks){
|
|
|
446
781
|
col.append(head);
|
|
447
782
|
const body=el('div','kanban-col-body');
|
|
448
783
|
if(!colTasks.length) body.append(el('div','kanban-empty','—'));
|
|
449
|
-
|
|
450
|
-
|
|
784
|
+
const expanded=kanbanExpanded.has(st);
|
|
785
|
+
const visible=expanded?colTasks:colTasks.slice(0,kanbanPageSize);
|
|
786
|
+
visible.forEach(t=> body.append(renderTask(t)));
|
|
787
|
+
col.append(body);
|
|
788
|
+
const remaining=colTasks.length-visible.length;
|
|
789
|
+
if(remaining>0){
|
|
790
|
+
const more=el('button','mini-btn kanban-more',t('board.kanbanShowMore',{n:remaining}));
|
|
791
|
+
more.type='button';
|
|
792
|
+
more.addEventListener('click',()=>{ kanbanExpanded.add(st); renderBoard(); });
|
|
793
|
+
col.append(more);
|
|
794
|
+
}
|
|
795
|
+
cols.append(col);
|
|
451
796
|
});
|
|
452
797
|
board.append(cols);
|
|
453
798
|
}
|
|
454
799
|
function updateBoardViewToggle(){ $$('#boardViewToggle .vt-btn').forEach(b=> b.classList.toggle('active', b.dataset.view===boardView)); }
|
|
800
|
+
function updateKanbanPageSizeToggle(){ $$('#kanbanPageSize .vt-btn').forEach(b=> b.classList.toggle('active', Number(b.dataset.size)===kanbanPageSize)); }
|
|
455
801
|
function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
|
|
456
802
|
function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
|
|
457
803
|
function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
|
|
@@ -540,13 +886,11 @@ function backlogRow(r){
|
|
|
540
886
|
|
|
541
887
|
// ---- phase expand state: we track which phases are EXPANDED (default = none), so on a big
|
|
542
888
|
// project the board opens compact — just phase headers with progress — and the user opens what
|
|
543
|
-
// they need. Persisted per phase title (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
|
|
549
|
-
let expandedPhases=loadExpanded();
|
|
889
|
+
// they need. Persisted per phase title, project-wide (config.json → expandedPhases, debounced —
|
|
890
|
+
// see saveSetting()); starts empty and is reconciled from server config once, in
|
|
891
|
+
// syncSettingsFromServer(). ----
|
|
892
|
+
function saveExpanded(set){ saveSetting({expandedPhases:[...set]}); }
|
|
893
|
+
let expandedPhases=new Set();
|
|
550
894
|
function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
|
|
551
895
|
function updatePhaseToggleAll(){
|
|
552
896
|
const btn=$('#phaseToggleAll'); if(!btn) return;
|
|
@@ -865,10 +1209,13 @@ async function saveAgent(id){
|
|
|
865
1209
|
const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
|
|
866
1210
|
if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
|
|
867
1211
|
}
|
|
868
|
-
// ---- design skins (data-design) —
|
|
1212
|
+
// ---- design skins (data-design) — a shared, project-level setting (not per-viewer, Sous-projet B):
|
|
1213
|
+
// first paint comes from the server (inject-design.js stamps data-design into index.html itself, from
|
|
1214
|
+
// config.design), instant switching still applies the DOM attribute directly client-side, and the
|
|
1215
|
+
// choice is persisted the same way as any other project setting (saveSetting -> /api/settings). ----
|
|
869
1216
|
function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
|
|
870
|
-
function applyDesign(id){ document.documentElement.setAttribute('data-design',id);
|
|
871
|
-
async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash();
|
|
1217
|
+
function applyDesign(id){ document.documentElement.setAttribute('data-design',id); }
|
|
1218
|
+
async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); saveSetting({design:id}); }
|
|
872
1219
|
|
|
873
1220
|
function renderSettings(){
|
|
874
1221
|
const c=(P&&P.config)||{};
|
|
@@ -880,10 +1227,9 @@ function renderSettings(){
|
|
|
880
1227
|
if(dsel){
|
|
881
1228
|
const designs=(typeof DESIGNS!=='undefined')?DESIGNS:[{id:'control-room',name:'Control Room'}];
|
|
882
1229
|
if(dsel.options.length!==designs.length){ dsel.innerHTML=''; designs.forEach(d=>{ const o=document.createElement('option'); o.value=d.id; o.textContent=d.name; if(d.desc) o.title=d.desc; dsel.append(o); }); }
|
|
883
|
-
// reconcile
|
|
884
|
-
|
|
885
|
-
if(
|
|
886
|
-
if(active && active!==currentDesign() && designs.some(d=>d.id===active)) document.documentElement.setAttribute('data-design',active);
|
|
1230
|
+
// reconcile from the project-level default (config.design) only — design is no longer a
|
|
1231
|
+
// per-viewer localStorage choice (Sous-projet B).
|
|
1232
|
+
if(c.design && c.design!==currentDesign() && designs.some(d=>d.id===c.design)) document.documentElement.setAttribute('data-design',c.design);
|
|
887
1233
|
dsel.value=currentDesign();
|
|
888
1234
|
}
|
|
889
1235
|
const box=$('#settingsReadonly');
|
|
@@ -895,6 +1241,8 @@ function renderSettings(){
|
|
|
895
1241
|
rows.forEach(([k,v])=>{ const r=el('div','settings-ro-row'); r.append(el('span','settings-ro-k',k), el('span','settings-ro-v',String(v))); box.append(r); });
|
|
896
1242
|
}
|
|
897
1243
|
const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
|
|
1244
|
+
renderNavTabsSettings();
|
|
1245
|
+
renderCommandsSettings();
|
|
898
1246
|
renderCustomize();
|
|
899
1247
|
}
|
|
900
1248
|
async function saveSettings(){
|
|
@@ -1077,7 +1425,7 @@ async function czSubmit(kind,description,agent){
|
|
|
1077
1425
|
// A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
|
|
1078
1426
|
// /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
|
|
1079
1427
|
// by a dedicated branch in tabFromPath()/navigateTab() instead.
|
|
1080
|
-
const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
|
|
1428
|
+
const ROUTES=['board','requests','attention','backlog','workflow','team','files','notes','meeting','chat','info','docs','personalize'];
|
|
1081
1429
|
// the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
|
|
1082
1430
|
// under that name still land on the Personalize tab instead of a blank panel.
|
|
1083
1431
|
function normalizeTab(t){ return t==='settings'?'personalize':t; }
|
|
@@ -1090,7 +1438,9 @@ function tabFromPath(){
|
|
|
1090
1438
|
function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
|
|
1091
1439
|
function navigateTab(tabId,push){
|
|
1092
1440
|
closeWfPop(); // an open popover is anchored to whichever panel is currently active — never carry it across
|
|
1093
|
-
|
|
1441
|
+
closeKanbanColsPop(); kanbanExpanded.clear(); // ditto for the Kanban columns popover + any "show more" reveals
|
|
1442
|
+
hideCmdMenu(); // ditto for the slash-command popover — never carry it across a tab switch
|
|
1443
|
+
activeTab=tabId; saveSetting({activeTab:tabId});
|
|
1094
1444
|
if(push!==false){
|
|
1095
1445
|
const isCustom=tabId.indexOf('custom:')===0;
|
|
1096
1446
|
history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
|
|
@@ -1418,80 +1768,42 @@ function renderFilesTree(){
|
|
|
1418
1768
|
}
|
|
1419
1769
|
function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
|
|
1420
1770
|
|
|
1421
|
-
// ----
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
const FILES_HL_LANG = {
|
|
1426
|
-
js: { comments:[['//','\n'],['/*','*/']], strings:['"',"'",'`'],
|
|
1427
|
-
keywords:'const let var function return if else for while do switch case break continue new class extends super this typeof instanceof in of try catch finally throw async await yield import export default from as null undefined true false void delete'.split(' ') },
|
|
1428
|
-
json: { comments:[], strings:['"'], keywords:'true false null'.split(' ') },
|
|
1429
|
-
css: { comments:[['/*','*/']], strings:['"',"'"], keywords:[] },
|
|
1430
|
-
html: { comments:[['<!--','-->']], strings:['"',"'"], keywords:[], tags:true },
|
|
1431
|
-
py: { comments:[['#','\n']], strings:['"',"'"],
|
|
1432
|
-
keywords:'def class return if elif else for while break continue pass import from as try except finally raise with lambda yield async await None True False and or not in is del global nonlocal'.split(' ') },
|
|
1433
|
-
sh: { comments:[['#','\n']], strings:['"',"'"],
|
|
1434
|
-
keywords:'if then else elif fi for while do done case esac function return exit export local readonly'.split(' ') },
|
|
1435
|
-
yml: { comments:[['#','\n']], strings:['"',"'"], keywords:'true false null'.split(' ') },
|
|
1436
|
-
};
|
|
1771
|
+
// ---- syntax highlighting — vendored Prism.js (self-hosted static files under
|
|
1772
|
+
// vendor/prism/, zero npm dependency; see index.html for the load order and Prism.manual note).
|
|
1773
|
+
// Anything not recognized (or with no lang mapping, or before Prism has loaded a grammar for it)
|
|
1774
|
+
// just renders as plain, escaped text — never a rendering error. ----
|
|
1437
1775
|
function filesHlLang(ext){
|
|
1438
|
-
if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return '
|
|
1776
|
+
if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'javascript';
|
|
1439
1777
|
if(ext==='json') return 'json';
|
|
1440
1778
|
if(ext==='css') return 'css';
|
|
1441
|
-
if(['html','htm'].includes(ext)) return '
|
|
1442
|
-
if(ext==='py') return '
|
|
1443
|
-
if(['sh','bash'].includes(ext)) return '
|
|
1444
|
-
if(['yml','yaml'].includes(ext)) return '
|
|
1779
|
+
if(['html','htm'].includes(ext)) return 'markup';
|
|
1780
|
+
if(ext==='py') return 'python';
|
|
1781
|
+
if(['sh','bash'].includes(ext)) return 'bash';
|
|
1782
|
+
if(['yml','yaml'].includes(ext)) return 'yaml';
|
|
1783
|
+
if(ext==='java') return 'java';
|
|
1784
|
+
if(ext==='go') return 'go';
|
|
1785
|
+
if(ext==='cs') return 'csharp';
|
|
1786
|
+
if(ext==='c') return 'c';
|
|
1787
|
+
if(['cpp','cc','cxx','h','hpp'].includes(ext)) return 'cpp';
|
|
1788
|
+
if(ext==='rs') return 'rust';
|
|
1789
|
+
if(ext==='rb') return 'ruby';
|
|
1790
|
+
if(ext==='php') return 'php';
|
|
1791
|
+
if(ext==='swift') return 'swift';
|
|
1792
|
+
if(['kt','kts'].includes(ext)) return 'kotlin';
|
|
1793
|
+
if(ext==='sql') return 'sql';
|
|
1794
|
+
if(['md','markdown'].includes(ext)) return 'markdown';
|
|
1795
|
+
if(ext==='dockerfile') return 'docker';
|
|
1445
1796
|
return null;
|
|
1446
1797
|
}
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1798
|
+
// Delegates to Prism.highlight(), which both tokenizes AND HTML-escapes the source as it goes —
|
|
1799
|
+
// this is the only place raw file content becomes markup, so the escaping is Prism's own, not
|
|
1800
|
+
// ours; falls back to plain escHtml() when there's no langKey or Prism has no grammar for it
|
|
1801
|
+
// (e.g. the vendored scripts failed to load), matching the previous "no lang matched" behavior.
|
|
1450
1802
|
function filesHighlight(src,langKey){
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
const n=src.length;
|
|
1454
|
-
let i=0,html='',plain='';
|
|
1455
|
-
const flushPlain=()=>{ if(plain){ html+=escHtml(plain); plain=''; } };
|
|
1456
|
-
const isWordChar=c=>/[A-Za-z0-9_$]/.test(c);
|
|
1457
|
-
while(i<n){
|
|
1458
|
-
let matched=false;
|
|
1459
|
-
// comments
|
|
1460
|
-
for(const [open,close] of lang.comments){
|
|
1461
|
-
if(src.startsWith(open,i)){
|
|
1462
|
-
const end = close==='\n' ? (src.indexOf('\n',i)===-1?n:src.indexOf('\n',i)) : (src.indexOf(close,i+open.length)===-1?n:src.indexOf(close,i+open.length)+close.length);
|
|
1463
|
-
flushPlain(); html+='<span class="hl-comment">'+escHtml(src.slice(i,end))+'</span>'; i=end; matched=true; break;
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
if(matched) continue;
|
|
1467
|
-
// strings
|
|
1468
|
-
if(lang.strings.includes(src[i])){
|
|
1469
|
-
const q=src[i]; let j=i+1;
|
|
1470
|
-
while(j<n && src[j]!==q){ if(src[j]==='\\') j++; j++; }
|
|
1471
|
-
j=Math.min(j+1,n);
|
|
1472
|
-
flushPlain(); html+='<span class="hl-string">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1473
|
-
}
|
|
1474
|
-
// html tags (bonus: <tag ...> / </tag>) — a light touch, not full attribute-vs-value parsing
|
|
1475
|
-
if(lang.tags && src[i]==='<' && /[a-zA-Z/!]/.test(src[i+1]||'')){
|
|
1476
|
-
const end=src.indexOf('>',i); const j=end===-1?n:end+1;
|
|
1477
|
-
flushPlain(); html+='<span class="hl-tag">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1478
|
-
}
|
|
1479
|
-
// numbers
|
|
1480
|
-
if(/[0-9]/.test(src[i]) && !isWordChar(src[i-1]||'')){
|
|
1481
|
-
let j=i; while(j<n && /[0-9.]/.test(src[j])) j++;
|
|
1482
|
-
flushPlain(); html+='<span class="hl-number">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1483
|
-
}
|
|
1484
|
-
// keywords
|
|
1485
|
-
if(isWordChar(src[i]) && !isWordChar(src[i-1]||'')){
|
|
1486
|
-
let j=i; while(j<n && isWordChar(src[j])) j++;
|
|
1487
|
-
const word=src.slice(i,j);
|
|
1488
|
-
if(lang.keywords.includes(word)){ flushPlain(); html+='<span class="hl-keyword">'+escHtml(word)+'</span>'; i=j; continue; }
|
|
1489
|
-
plain+=word; i=j; continue;
|
|
1490
|
-
}
|
|
1491
|
-
plain+=src[i]; i++;
|
|
1803
|
+
if(langKey && window.Prism && Prism.languages && Prism.languages[langKey]){
|
|
1804
|
+
return Prism.highlight(src, Prism.languages[langKey], langKey);
|
|
1492
1805
|
}
|
|
1493
|
-
|
|
1494
|
-
return html;
|
|
1806
|
+
return escHtml(src);
|
|
1495
1807
|
}
|
|
1496
1808
|
// A textarea can't render colored text itself, so this overlays one, transparent, on top of a
|
|
1497
1809
|
// highlighted <pre><code> "backdrop" showing through it (the standard technique for a highlighted
|
|
@@ -1655,6 +1967,208 @@ async function submitFilesCreate(){
|
|
|
1655
1967
|
if(kind!=='dir') openFilesFile(rel);
|
|
1656
1968
|
}
|
|
1657
1969
|
|
|
1970
|
+
// ---- Bloc note (Sous-projet C, Task 2) ----------------------------------------------------------
|
|
1971
|
+
// One freeform Markdown scratchpad per project, stored at .spectoflow/notes.md — reuses the exact
|
|
1972
|
+
// same files.read/files.write ops (and their /api/files/read|write routes) the File Explorer already
|
|
1973
|
+
// drives, and the exact same filesCodeEditor() backdrop+textarea component the Markdown file editor
|
|
1974
|
+
// there uses (langKey:'markdown', matching renderFilesMd's own call). No parallel persistence path.
|
|
1975
|
+
// The path is relative to the PROJECT ROOT (same convention as every other /api/files/* call — see
|
|
1976
|
+
// files.js's safePath()), so '.spectoflow/notes.md' is what actually lands inside .spectoflow/.
|
|
1977
|
+
const NOTES_PATH = '.spectoflow/notes.md';
|
|
1978
|
+
let notesLoaded = false, notesSaveTimer = null;
|
|
1979
|
+
// Only the FIRST activation fetches (mirrors renderFiles()'s own "fetch once" guard) — a full SSE
|
|
1980
|
+
// reload must never yank a note out from under an in-progress, not-yet-saved edit.
|
|
1981
|
+
function renderNotes(){
|
|
1982
|
+
if(activeTab!=='notes' || notesLoaded) return;
|
|
1983
|
+
notesLoaded = true;
|
|
1984
|
+
loadNotes();
|
|
1985
|
+
}
|
|
1986
|
+
async function loadNotes(){
|
|
1987
|
+
const box=$('#notesEditor'); if(!box) return;
|
|
1988
|
+
box.innerHTML=''; box.append(el('div','files-empty',t('drawer.loading')));
|
|
1989
|
+
let content='';
|
|
1990
|
+
try{
|
|
1991
|
+
const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:NOTES_PATH})));
|
|
1992
|
+
const d=await r.json().catch(()=>({}));
|
|
1993
|
+
// "Not found." just means no note has ever been saved yet — an empty scratchpad, not an error.
|
|
1994
|
+
if(r.ok) content=d.content||'';
|
|
1995
|
+
else if(d.error && d.error!=='Not found.') throw new Error(d.error);
|
|
1996
|
+
}catch(err){ notesSetStatus('error'); }
|
|
1997
|
+
box.innerHTML='';
|
|
1998
|
+
const {wrap}=filesCodeEditor(content,'markdown',(v)=>notesScheduleSave(v));
|
|
1999
|
+
box.append(wrap);
|
|
2000
|
+
}
|
|
2001
|
+
// A scratchpad should feel like it's always saved, not like a form to submit — every keystroke
|
|
2002
|
+
// (debounced) writes straight through files.write, no explicit Save button. 700ms: a bit longer than
|
|
2003
|
+
// the 400ms generic settings debounce (saveSetting()), since this carries larger free text on every
|
|
2004
|
+
// keystroke rather than one small settings patch, but still short enough that a reload moments after
|
|
2005
|
+
// typing never loses more than a fraction of a second of work.
|
|
2006
|
+
function notesScheduleSave(content){
|
|
2007
|
+
notesSetStatus('saving');
|
|
2008
|
+
clearTimeout(notesSaveTimer);
|
|
2009
|
+
notesSaveTimer=setTimeout(()=>notesSave(content),700);
|
|
2010
|
+
}
|
|
2011
|
+
async function notesSave(content){
|
|
2012
|
+
try{
|
|
2013
|
+
const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:NOTES_PATH,content})});
|
|
2014
|
+
const d=await r.json().catch(()=>({}));
|
|
2015
|
+
if(!r.ok) throw new Error(d.error||'error');
|
|
2016
|
+
notesSetStatus('saved');
|
|
2017
|
+
}catch(err){ notesSetStatus('error'); }
|
|
2018
|
+
}
|
|
2019
|
+
// Small, unobtrusive status label next to the note — mirrors the Files tab's own tip pattern
|
|
2020
|
+
// (files.saved/files.saveError) rather than inventing a new toast/notification system.
|
|
2021
|
+
function notesSetStatus(state){
|
|
2022
|
+
const tip=$('#notesStatus'); if(!tip) return;
|
|
2023
|
+
if(state==='saving'){ tip.textContent=t('notes.saving'); tip.className='note-status is-saving'; }
|
|
2024
|
+
else if(state==='saved'){
|
|
2025
|
+
tip.textContent=t('files.saved'); tip.className='note-status is-saved';
|
|
2026
|
+
setTimeout(()=>{ if(tip.classList.contains('is-saved')) tip.textContent=''; },1500);
|
|
2027
|
+
} else { tip.textContent=t('files.saveError'); tip.className='note-status is-error'; }
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// ---- Daily meeting (Sous-projet C, Task 3) ------------------------------------------------------
|
|
2031
|
+
// One dated Markdown note per day, .spectoflow/meetings/<date>.md. MANUAL editing reuses the exact
|
|
2032
|
+
// same files.read/files.write ops + filesCodeEditor() component as Bloc note above (700ms debounced
|
|
2033
|
+
// autosave, "Not found." treated as an empty editor). GENERATE is the new half: POST
|
|
2034
|
+
// /api/meeting/generate spawns the active agent (same run-start/run-end/change SSE shape as
|
|
2035
|
+
// chat.summarize — see updateChatBusyUI() above, which already disables #meetingGenBtn and drives
|
|
2036
|
+
// #meetingRunStatus for free) and writes straight into the SAME file this tab already reads/writes,
|
|
2037
|
+
// so a generated note lands in the editor exactly like a manual edit would.
|
|
2038
|
+
// "Today" is always the SERVER's local date (P.todayDate, from GET /api/project — see meeting.js's
|
|
2039
|
+
// todayLocal()), never the browser's: this is a single-writer local tool and the filename itself must
|
|
2040
|
+
// be unambiguous, so a viewer's own time zone must never decide which file "today" resolves to.
|
|
2041
|
+
let meetingHistoryLoaded=false, meetingSelectedDate=null, meetingEditorDate=null, meetingAwaitingGen=false, meetingSaveTimer=null;
|
|
2042
|
+
function meetingFilePath(date){ return '.spectoflow/meetings/'+date+'.md'; }
|
|
2043
|
+
function meetingToday(){ return (P && P.todayDate) || new Date().toISOString().slice(0,10); } // fallback only until the first /api/project response lands
|
|
2044
|
+
// Reuses files.tree (already fetched by the Files tab for the same project) rather than a new
|
|
2045
|
+
// meeting.list op — the .spectoflow/meetings/<date>.md files are already ordinary nodes in that tree
|
|
2046
|
+
// (files.js's buildTree() only denies .git/node_modules), so the dated history is derived client-side
|
|
2047
|
+
// from data this tab needs to fetch anyway.
|
|
2048
|
+
function meetingDatesFromTree(tree){
|
|
2049
|
+
const dot=(tree||[]).find(n=>n.name==='.spectoflow'&&n.type==='dir');
|
|
2050
|
+
const mtg=dot && (dot.children||[]).find(n=>n.name==='meetings'&&n.type==='dir');
|
|
2051
|
+
const kids=mtg ? (mtg.children||[]) : [];
|
|
2052
|
+
return kids.filter(n=>n.type==='file'&&/^\d{4}-\d{2}-\d{2}\.md$/.test(n.name)).map(n=>n.name.slice(0,-3)).sort().reverse(); // newest first
|
|
2053
|
+
}
|
|
2054
|
+
// Only the FIRST activation fetches the tree (mirrors renderNotes()'s own "fetch once" guard) — an
|
|
2055
|
+
// unrelated SSE reload must never re-walk the tree out from under the viewer mid-browse. The editor
|
|
2056
|
+
// itself is reloaded separately, keyed off the selected date (see renderMeeting() below).
|
|
2057
|
+
function renderMeeting(){
|
|
2058
|
+
if(activeTab!=='meeting') return;
|
|
2059
|
+
if(!meetingSelectedDate) meetingSelectedDate=meetingToday();
|
|
2060
|
+
if(!meetingHistoryLoaded){ meetingHistoryLoaded=true; loadMeetingHistory(); }
|
|
2061
|
+
if(meetingEditorDate!==meetingSelectedDate) loadMeetingEditor(meetingSelectedDate);
|
|
2062
|
+
// A generate run just finished (run-end already flipped sseBusy off, by the time the debounced
|
|
2063
|
+
// 'change' reload that follows it reaches render()) — force a refetch even though the date itself
|
|
2064
|
+
// didn't change, so the generated content actually appears.
|
|
2065
|
+
if(meetingAwaitingGen && !sseBusy){ meetingAwaitingGen=false; loadMeetingEditor(meetingSelectedDate,true); loadMeetingHistory(); }
|
|
2066
|
+
meetingSyncHistoryHighlight(); // cheap DOM-only sync — must run every tick, not just after a (network) history refetch
|
|
2067
|
+
meetingRenderToolbar();
|
|
2068
|
+
}
|
|
2069
|
+
async function loadMeetingHistory(){
|
|
2070
|
+
const box=$('#meetingHistory'); if(!box) return;
|
|
2071
|
+
let dates=[];
|
|
2072
|
+
try{
|
|
2073
|
+
const r=await fetch(withProject('/api/files/tree'));
|
|
2074
|
+
const d=await r.json().catch(()=>({}));
|
|
2075
|
+
if(r.ok) dates=meetingDatesFromTree(d.tree);
|
|
2076
|
+
}catch(err){ /* history is best-effort — an empty list still leaves today selectable/creatable */ }
|
|
2077
|
+
const today=meetingToday();
|
|
2078
|
+
const all=[today,...dates.filter(x=>x!==today)]; // today is always selectable/creatable, even with zero history yet
|
|
2079
|
+
box.innerHTML='';
|
|
2080
|
+
all.forEach(date=>{
|
|
2081
|
+
const li=el('li',date===meetingSelectedDate?'is-active':'', date===today?(date+' · '+t('meeting.today')):date);
|
|
2082
|
+
li.dataset.date=date;
|
|
2083
|
+
li.addEventListener('click',()=>{ if(date===meetingSelectedDate) return; meetingSelectedDate=date; renderMeeting(); });
|
|
2084
|
+
box.append(li);
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
// Just clicking a different history row changes meetingSelectedDate without re-fetching the tree (no
|
|
2088
|
+
// network round-trip needed to know which row is now selected) — so the "is-active" class has to be
|
|
2089
|
+
// re-synced from existing DOM rows on every renderMeeting() tick, not only when loadMeetingHistory()
|
|
2090
|
+
// itself (re)builds the list.
|
|
2091
|
+
function meetingSyncHistoryHighlight(){
|
|
2092
|
+
const box=$('#meetingHistory'); if(!box) return;
|
|
2093
|
+
box.querySelectorAll('li').forEach(li=>li.classList.toggle('is-active',li.dataset.date===meetingSelectedDate));
|
|
2094
|
+
}
|
|
2095
|
+
async function loadMeetingEditor(date,force){
|
|
2096
|
+
if(!force && meetingEditorDate===date) return;
|
|
2097
|
+
const box=$('#meetingEditor'); if(!box) return;
|
|
2098
|
+
meetingEditorDate=date;
|
|
2099
|
+
box.innerHTML=''; box.append(el('div','files-empty',t('drawer.loading')));
|
|
2100
|
+
let content='';
|
|
2101
|
+
try{
|
|
2102
|
+
const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:meetingFilePath(date)})));
|
|
2103
|
+
const d=await r.json().catch(()=>({}));
|
|
2104
|
+
// "Not found." just means no entry has been written for this date yet — an empty editor, not an error.
|
|
2105
|
+
if(r.ok) content=d.content||'';
|
|
2106
|
+
else if(d.error && d.error!=='Not found.') throw new Error(d.error);
|
|
2107
|
+
}catch(err){ meetingSetStatus('error'); }
|
|
2108
|
+
if(meetingSelectedDate!==date) return; // the viewer switched dates while this fetch was in flight
|
|
2109
|
+
box.innerHTML='';
|
|
2110
|
+
const {wrap}=filesCodeEditor(content,'markdown',(v)=>meetingScheduleSave(date,v));
|
|
2111
|
+
box.append(wrap);
|
|
2112
|
+
meetingRenderToolbar();
|
|
2113
|
+
}
|
|
2114
|
+
function meetingScheduleSave(date,content){
|
|
2115
|
+
meetingSetStatus('saving');
|
|
2116
|
+
clearTimeout(meetingSaveTimer);
|
|
2117
|
+
meetingSaveTimer=setTimeout(()=>meetingSave(date,content),700);
|
|
2118
|
+
}
|
|
2119
|
+
async function meetingSave(date,content){
|
|
2120
|
+
try{
|
|
2121
|
+
const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:meetingFilePath(date),content})});
|
|
2122
|
+
const d=await r.json().catch(()=>({}));
|
|
2123
|
+
if(!r.ok) throw new Error(d.error||'error');
|
|
2124
|
+
meetingSetStatus('saved');
|
|
2125
|
+
}catch(err){ meetingSetStatus('error'); }
|
|
2126
|
+
}
|
|
2127
|
+
// Reuses the Files tab's own neutral tip classes/copy (files.saved/files.saveError) — this panel is a
|
|
2128
|
+
// journal/report, not a post-it, so Notes' own themed .note-status treatment doesn't belong here.
|
|
2129
|
+
function meetingSetStatus(state){
|
|
2130
|
+
const tip=$('#meetingStatus'); if(!tip) return;
|
|
2131
|
+
if(state==='saving'){ tip.textContent=t('notes.saving'); tip.className='files-saved-tip'; }
|
|
2132
|
+
else if(state==='saved'){
|
|
2133
|
+
tip.textContent=t('files.saved'); tip.className='files-saved-tip';
|
|
2134
|
+
setTimeout(()=>{ if(tip.textContent===t('files.saved')) tip.textContent=''; },1500);
|
|
2135
|
+
} else { tip.textContent=t('files.saveError'); tip.className='files-error-tip'; }
|
|
2136
|
+
}
|
|
2137
|
+
function meetingRenderToolbar(){
|
|
2138
|
+
const dateEl=$('#meetingDate'); if(dateEl) dateEl.textContent=meetingSelectedDate||'';
|
|
2139
|
+
const isToday=meetingSelectedDate===meetingToday();
|
|
2140
|
+
const genBtn=$('#meetingGenBtn'); if(genBtn) genBtn.hidden=!isToday; // Generate is offered for TODAY only — see report
|
|
2141
|
+
if(!isToday) meetingHideConfirm();
|
|
2142
|
+
}
|
|
2143
|
+
function meetingHasContent(){
|
|
2144
|
+
const ta=$('#meetingEditor .files-code-input');
|
|
2145
|
+
return !!(ta && ta.value.trim());
|
|
2146
|
+
}
|
|
2147
|
+
function meetingHideConfirm(){ const box=$('#meetingConfirm'); if(box) box.hidden=true; }
|
|
2148
|
+
// Overwrite safety, no native confirm()/alert() (blocks the whole tab, including SSE): reuses this
|
|
2149
|
+
// codebase's existing non-blocking "explicit second action" shape (see filesDiscardBtn's own comment
|
|
2150
|
+
// above) — an inline bar with its own Cancel/Generate-anyway buttons, shown only when the date being
|
|
2151
|
+
// generated already has non-empty content, instead of a native dialog.
|
|
2152
|
+
function meetingGenerateClick(){
|
|
2153
|
+
if(isChatBusy()) return;
|
|
2154
|
+
if(meetingHasContent()){ const box=$('#meetingConfirm'); if(box) box.hidden=false; return; }
|
|
2155
|
+
doMeetingGenerate();
|
|
2156
|
+
}
|
|
2157
|
+
async function doMeetingGenerate(){
|
|
2158
|
+
meetingAwaitingGen=true;
|
|
2159
|
+
const agentEl=$('#tabRunAgent')||$('#runAgent');
|
|
2160
|
+
try{
|
|
2161
|
+
const r=await fetch(withProject('/api/meeting/generate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:agentEl?agentEl.value:undefined,date:meetingSelectedDate})});
|
|
2162
|
+
if(!r.ok){
|
|
2163
|
+
// The op threw before ever spawning (e.g. "No runner configured…") — no run-start/run-end SSE
|
|
2164
|
+
// pair will ever arrive to clear this, so surface the error and reset the flag right here.
|
|
2165
|
+
meetingAwaitingGen=false;
|
|
2166
|
+
const d=await r.json().catch(()=>({}));
|
|
2167
|
+
const tip=$('#meetingStatus'); if(tip){ tip.textContent=d.error||t('files.saveError'); tip.className='files-error-tip'; }
|
|
2168
|
+
}
|
|
2169
|
+
}catch(err){ meetingAwaitingGen=false; meetingSetStatus('error'); }
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1658
2172
|
function openDrawer(id,keep){
|
|
1659
2173
|
// named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
|
|
1660
2174
|
// function calls it repeatedly below; shadowing it with a task variable would break every call.
|
|
@@ -1701,8 +2215,9 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
|
|
|
1701
2215
|
// and render()'s SSE-driven re-render (triggered by the snapshot write / polling) re-applies it
|
|
1702
2216
|
// too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
|
|
1703
2217
|
// 'change'/'message' event that lands right after a click.
|
|
1704
|
-
// initial tab: the URL path wins (deep-link / refresh), else the persisted tab
|
|
1705
|
-
|
|
2218
|
+
// initial tab: the URL path wins (deep-link / refresh), else board — the persisted tab (config.json
|
|
2219
|
+
// -> activeTab) is reconciled in once P.config is available, see syncSettingsFromServer().
|
|
2220
|
+
let activeTab = tabFromPath() || 'board';
|
|
1706
2221
|
openTaskId = taskFromPath(); // deep-link straight to a task drawer
|
|
1707
2222
|
function applyActiveTab(){
|
|
1708
2223
|
$$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
|
|
@@ -1747,18 +2262,104 @@ applyActiveTab(); // sync to the resolved tab before the first render
|
|
|
1747
2262
|
if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
|
|
1748
2263
|
// filters (status chips + search) — client-side only, does not write anything
|
|
1749
2264
|
$$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
|
|
1750
|
-
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
|
|
2265
|
+
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; kanbanExpanded.clear(); renderBoard(); });
|
|
1751
2266
|
// board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
|
|
1752
|
-
$$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view;
|
|
2267
|
+
$$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; saveSetting({boardView}); renderBoard(); }));
|
|
2268
|
+
|
|
2269
|
+
// ---- Kanban column visibility + page size (Sous-projet B, Task 2) ----
|
|
2270
|
+
// A small anchored popover (same interaction shape as the Workflow step popover: opens on click,
|
|
2271
|
+
// closes on outside click/Esc, keyboard-accessible) lets the viewer toggle any status column off
|
|
2272
|
+
// except the last one still visible — mirroring the server's own "never persist an empty
|
|
2273
|
+
// kanbanColumns" rule (writeConfig() in ops.js) so the UI never lets you attempt what the server
|
|
2274
|
+
// would silently refuse anyway.
|
|
2275
|
+
let kanbanColsOpen=false;
|
|
2276
|
+
function toggleKanbanColsPop(){ kanbanColsOpen?closeKanbanColsPop():openKanbanColsPop(); }
|
|
2277
|
+
function openKanbanColsPop(){
|
|
2278
|
+
kanbanColsOpen=true;
|
|
2279
|
+
renderKanbanColsPop();
|
|
2280
|
+
const btn=$('#kanbanColsBtn'); if(btn) btn.setAttribute('aria-expanded','true');
|
|
2281
|
+
}
|
|
2282
|
+
function closeKanbanColsPop(){
|
|
2283
|
+
kanbanColsOpen=false;
|
|
2284
|
+
const p=$('#kanbanColsPop'); if(p) p.hidden=true;
|
|
2285
|
+
const btn=$('#kanbanColsBtn'); if(btn) btn.setAttribute('aria-expanded','false');
|
|
2286
|
+
}
|
|
2287
|
+
function renderKanbanColsPop(){
|
|
2288
|
+
const pop=$('#kanbanColsPop'); const btn=$('#kanbanColsBtn'); if(!pop||!btn) return;
|
|
2289
|
+
pop.innerHTML='';
|
|
2290
|
+
Object.keys(STATUS).forEach(st=>{
|
|
2291
|
+
const checked=kanbanColumns.includes(st);
|
|
2292
|
+
const isLastOne=checked&&kanbanColumns.length<=1;
|
|
2293
|
+
const row=document.createElement('label'); row.className='kcp-row'+(isLastOne?' is-locked':'');
|
|
2294
|
+
const cb=document.createElement('input'); cb.type='checkbox'; cb.checked=checked; cb.disabled=isLastOne;
|
|
2295
|
+
cb.addEventListener('change',()=> toggleKanbanColumn(st));
|
|
2296
|
+
row.append(cb, el('span',null,STATUS[st]));
|
|
2297
|
+
pop.append(row);
|
|
2298
|
+
});
|
|
2299
|
+
pop.append(el('div','kcp-note',t('board.kanbanColumnsHint')));
|
|
2300
|
+
positionKanbanColsPop(btn,pop);
|
|
2301
|
+
}
|
|
2302
|
+
// Real bug found in QA: with no clamping, a popover taller than the room below its anchor simply
|
|
2303
|
+
// overflowed off the bottom of the viewport with no way to scroll to it (it's position:fixed, so it
|
|
2304
|
+
// never grows the document's own scrollable height). Cap its height to whatever room is actually
|
|
2305
|
+
// available (flipping above the button when that has more room, same idea as positionWfPop()) and
|
|
2306
|
+
// let it scroll internally past that.
|
|
2307
|
+
function positionKanbanColsPop(anchorEl,pop){
|
|
2308
|
+
pop.style.visibility='hidden'; pop.hidden=false; pop.style.maxHeight='';
|
|
2309
|
+
const r=anchorEl.getBoundingClientRect();
|
|
2310
|
+
const vpH=window.innerHeight, m=8, edge=10;
|
|
2311
|
+
const below=vpH-r.bottom-m-edge, above=r.top-m-edge;
|
|
2312
|
+
const useAbove=below<160&&above>below;
|
|
2313
|
+
pop.style.maxHeight=Math.max(120,(useAbove?above:below))+'px';
|
|
2314
|
+
pop.style.overflowY='auto';
|
|
2315
|
+
const pw=pop.offsetWidth||200;
|
|
2316
|
+
const left=Math.max(10,Math.min(r.left,window.innerWidth-pw-10));
|
|
2317
|
+
pop.style.left=left+'px';
|
|
2318
|
+
if(useAbove) pop.style.top=(r.top-m-pop.offsetHeight)+'px';
|
|
2319
|
+
else pop.style.top=(r.bottom+6)+'px';
|
|
2320
|
+
pop.style.visibility='';
|
|
2321
|
+
}
|
|
2322
|
+
function toggleKanbanColumn(st){
|
|
2323
|
+
let next;
|
|
2324
|
+
if(kanbanColumns.includes(st)){
|
|
2325
|
+
if(kanbanColumns.length<=1) return; // the last visible column can't be disabled — same rule the server enforces
|
|
2326
|
+
next=kanbanColumns.filter(x=>x!==st);
|
|
2327
|
+
} else {
|
|
2328
|
+
next=[...kanbanColumns,st];
|
|
2329
|
+
}
|
|
2330
|
+
kanbanColumns=next;
|
|
2331
|
+
saveSetting({kanbanColumns});
|
|
2332
|
+
renderBoard();
|
|
2333
|
+
if(kanbanColsOpen) renderKanbanColsPop(); // keep the open popover's checkbox/lock state in sync
|
|
2334
|
+
}
|
|
2335
|
+
const kanbanColsBtn=$('#kanbanColsBtn');
|
|
2336
|
+
if(kanbanColsBtn) kanbanColsBtn.addEventListener('click',e=>{ e.stopPropagation(); toggleKanbanColsPop(); });
|
|
2337
|
+
document.addEventListener('click',e=>{ if(kanbanColsOpen && !e.target.closest('#kanbanColsPop') && !e.target.closest('#kanbanColsBtn')) closeKanbanColsPop(); });
|
|
2338
|
+
document.addEventListener('keydown',e=>{ if(e.key==='Escape' && kanbanColsOpen) closeKanbanColsPop(); });
|
|
2339
|
+
window.addEventListener('resize',()=>{ if(kanbanColsOpen) closeKanbanColsPop(); });
|
|
2340
|
+
// Kanban page size (10/20) — same view-toggle visual pattern as List/Kanban itself. Changing it
|
|
2341
|
+
// resets every column's "show more" reveal state (kanbanExpanded) since the new cap applies fresh.
|
|
2342
|
+
$$('#kanbanPageSize .vt-btn').forEach(b=> b.addEventListener('click',()=>{
|
|
2343
|
+
const size=Number(b.dataset.size); if(size!==10&&size!==20) return;
|
|
2344
|
+
kanbanPageSize=size; kanbanExpanded.clear(); saveSetting({kanbanPageSize}); renderBoard();
|
|
2345
|
+
}));
|
|
1753
2346
|
// right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
|
|
1754
2347
|
// persisted per viewer like every other layout preference here
|
|
1755
2348
|
function applySideHidden(){
|
|
1756
|
-
const panel=$('.panel[data-panel="board"]');
|
|
2349
|
+
const panel=$('.panel[data-panel="board"]');
|
|
1757
2350
|
if(panel) panel.classList.toggle('side-hidden', sideHidden);
|
|
1758
|
-
|
|
2351
|
+
// two entry points (topbar + inline on the sidebar itself) drive the same state — keep both in sync
|
|
2352
|
+
for(const btn of $$('#sideToggle, #sideToggleInline')){
|
|
2353
|
+
btn.setAttribute('aria-pressed', String(sideHidden));
|
|
2354
|
+
btn.title=t(sideHidden?'board.showSidebar':'board.hideSidebar');
|
|
2355
|
+
}
|
|
1759
2356
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
2357
|
+
function toggleSideHidden(){
|
|
2358
|
+
sideHidden=!sideHidden;
|
|
2359
|
+
saveSetting({sideHidden});
|
|
2360
|
+
applySideHidden();
|
|
2361
|
+
}
|
|
2362
|
+
for(const btn of $$('#sideToggle, #sideToggleInline')) btn.addEventListener('click', toggleSideHidden);
|
|
1762
2363
|
applySideHidden();
|
|
1763
2364
|
// expand / collapse all phases (List view) — keeps a big board compact by default
|
|
1764
2365
|
const phaseToggleAllBtn=$('#phaseToggleAll');
|
|
@@ -1813,9 +2414,11 @@ const topAgentSel=$('#topAgent'); if(topAgentSel) topAgentSel.addEventListener('
|
|
|
1813
2414
|
const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('change',(e)=>saveAgent(e.target.value));
|
|
1814
2415
|
// hub-mode pill — only ever shown when this tab is actually served via the multi-project hub
|
|
1815
2416
|
(function(){ const p=$('#hubPill'); if(p && PROJECT_ID) p.hidden=false; })();
|
|
1816
|
-
// theme
|
|
1817
|
-
(
|
|
1818
|
-
|
|
2417
|
+
// theme — a real project setting now (config.json -> theme), reconciled onto <html data-theme> once
|
|
2418
|
+
// P.config is available (syncSettingsFromServer()); defaults to dark (CSS's own default) until then.
|
|
2419
|
+
(function(){
|
|
2420
|
+
$('#themeToggle').addEventListener('click',()=>{ const c=document.documentElement.getAttribute('data-theme'); const n=c==='dark'?'light':'dark'; document.documentElement.setAttribute('data-theme',n); saveSetting({theme:n}); });
|
|
2421
|
+
})();
|
|
1819
2422
|
// chart tooltip — a single floating layer, shown on hover over any [data-tip]
|
|
1820
2423
|
// element (area-hit rects, future hit targets) and positioned near the cursor.
|
|
1821
2424
|
(function(){
|
|
@@ -1837,10 +2440,14 @@ const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('
|
|
|
1837
2440
|
$$('[data-icon]').forEach(node=>{ const svg=ICON[node.dataset.icon]; if(svg) node.innerHTML=svg; });
|
|
1838
2441
|
})();
|
|
1839
2442
|
// chat widget
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2443
|
+
// user-driven chat toggles persist (debounced) — see setChat()'s own comment for why the DOM change
|
|
2444
|
+
// and the save are split apart.
|
|
2445
|
+
function toggleChat(open){ setChat(open); saveSetting({chatOpen:open}); }
|
|
2446
|
+
$('#runQuickBtn').addEventListener('click',()=> toggleChat(true));
|
|
2447
|
+
$('#chatFab').addEventListener('click',()=> toggleChat($('#chat').getAttribute('aria-hidden')==='true'));
|
|
2448
|
+
$('#chatClose').addEventListener('click',()=> toggleChat(false));
|
|
2449
|
+
// initial chatOpen state (if saved server-side) is applied by syncSettingsFromServer(), called from
|
|
2450
|
+
// load() once P.config is available — nothing to do here at parse time.
|
|
1844
2451
|
$('#runBtn').addEventListener('click',()=>doRun());
|
|
1845
2452
|
$('#orchBtn').addEventListener('click',()=>doOrchestrate());
|
|
1846
2453
|
$('#widgetSummarizeBtn').addEventListener('click',()=>summarizeChat($('#runAgent')));
|
|
@@ -1851,7 +2458,20 @@ $('#tabRunBtn').addEventListener('click',()=>doRun($('#tabRunPrompt'),$('#tabRun
|
|
|
1851
2458
|
$('#tabOrchBtn').addEventListener('click',()=>doOrchestrate($('#tabRunPrompt')));
|
|
1852
2459
|
$('#tabSummarizeBtn').addEventListener('click',()=>summarizeChat($('#tabRunAgent')));
|
|
1853
2460
|
$('#tabClearBtn').addEventListener('click',clearChat);
|
|
2461
|
+
// Daily meeting (Sous-projet C, Task 3) — Generate + its inline, non-blocking overwrite confirm.
|
|
2462
|
+
$('#meetingGenBtn').addEventListener('click',meetingGenerateClick);
|
|
2463
|
+
$('#meetingConfirmBtn').addEventListener('click',()=>{ meetingHideConfirm(); doMeetingGenerate(); });
|
|
2464
|
+
$('#meetingCancelBtn').addEventListener('click',meetingHideConfirm);
|
|
1854
2465
|
$('#tabRunPrompt').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter')doRun($('#tabRunPrompt'),$('#tabRunAgent')); });
|
|
2466
|
+
// Slash-command autocomplete — wired on both chat textareas (floating widget + Chat tab).
|
|
2467
|
+
['#runPrompt','#tabRunPrompt'].forEach((sel)=>{
|
|
2468
|
+
const inp=$(sel); if(!inp) return;
|
|
2469
|
+
inp.addEventListener('input',()=>openCmdMenu(inp));
|
|
2470
|
+
inp.addEventListener('keydown',(e)=>{ if(cmdMenuKey(e)){ e.preventDefault(); e.stopPropagation(); } }, true); // intercept menu navigation keys only while the menu is open
|
|
2471
|
+
inp.addEventListener('blur',()=>setTimeout(hideCmdMenu,120)); // let a menu mousedown win first
|
|
2472
|
+
});
|
|
2473
|
+
document.addEventListener('click',(e)=>{ const m=cmdMenuEl(); if(m&&!m.hidden&&!m.contains(e.target)&&!/^(runPrompt|tabRunPrompt)$/.test(e.target.id)) hideCmdMenu(); });
|
|
2474
|
+
window.addEventListener('resize',()=>{ if(_cmdMenuInput) positionCmdMenu(_cmdMenuInput); });
|
|
1855
2475
|
$('#drawerClose').addEventListener('click',closeDrawer);
|
|
1856
2476
|
$('#drawerScrim').addEventListener('click',closeDrawer);
|
|
1857
2477
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDrawer(); });
|