spectoflow 0.23.5 → 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 +49 -16
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +226 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/connector.js +193 -0
- package/lib/dashboard/handlers.js +53 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +134 -51
- package/lib/dashboard/inject-design.js +41 -0
- package/lib/dashboard/meeting.js +116 -0
- package/lib/dashboard/ops.js +257 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +743 -124
- package/{templates → lib}/dashboard/public/charts.js +3 -3
- package/lib/dashboard/public/commands.js +77 -0
- package/{templates → lib}/dashboard/public/designs/console.css +7 -19
- package/{templates → lib}/dashboard/public/designs/orbit.css +3 -4
- package/{templates → 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/{templates → lib}/dashboard/public/hub.html +1 -1
- package/{templates → lib}/dashboard/public/hub.js +27 -4
- package/{templates → lib}/dashboard/public/i18n.js +60 -18
- package/{templates → lib}/dashboard/public/icons.js +2 -0
- package/{templates → lib}/dashboard/public/index.html +106 -13
- package/{templates → 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/{templates → lib}/dashboard/runner.js +13 -7
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +119 -0
- package/package.json +3 -3
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/config.json +23 -0
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /package/{templates → lib}/dashboard/public/stats.js +0 -0
|
@@ -6,20 +6,48 @@ 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
|
|
20
|
-
// reaches this file directly; it 302s to a /p/<id>/... URL first. Null
|
|
21
|
-
//
|
|
22
|
-
// that case, preserving today's exact single-project behavior.
|
|
49
|
+
// reaches this file directly; it 302s to a /p/<id>/... URL first. Null defensively (no prefix at
|
|
50
|
+
// all) — every helper below no-ops in that case rather than assuming the prefix is always present.
|
|
23
51
|
const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
|
|
24
52
|
// Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
|
|
25
53
|
// attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
|
|
@@ -32,6 +60,232 @@ function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : res
|
|
|
32
60
|
// strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
|
|
33
61
|
function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
|
|
34
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
|
+
|
|
35
289
|
const $ = (s,r=document)=>r.querySelector(s);
|
|
36
290
|
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
|
|
37
291
|
const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
|
|
@@ -39,7 +293,9 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
|
|
|
39
293
|
const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
|
|
40
294
|
|
|
41
295
|
async function load(){
|
|
42
|
-
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);
|
|
43
299
|
if(openTaskId) openDrawer(openTaskId,true);
|
|
44
300
|
}
|
|
45
301
|
// Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
|
|
@@ -122,10 +378,12 @@ function appendRaw(chunk){
|
|
|
122
378
|
scrollChat(container);
|
|
123
379
|
});
|
|
124
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.
|
|
125
384
|
function setChat(open){
|
|
126
385
|
$('#chat').setAttribute('aria-hidden', open?'false':'true');
|
|
127
386
|
$('#chatFab').classList.toggle('is-open',open);
|
|
128
|
-
try{ localStorage.setItem('spf-chat', open?'1':'0'); }catch{}
|
|
129
387
|
// Land where you can actually type, not wherever the log happened to be scrolled last.
|
|
130
388
|
if(open) setTimeout(()=>{ scrollChat($('#chatLog')); $('#runPrompt').focus(); },60);
|
|
131
389
|
}
|
|
@@ -135,20 +393,86 @@ function setChat(open){
|
|
|
135
393
|
// isChatBusy() also guards the Ctrl/Cmd+Enter keyboard shortcuts, which call doRun() directly and
|
|
136
394
|
// would otherwise bypass the buttons' own disabled state.
|
|
137
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
|
+
}
|
|
138
402
|
async function doRun(promptEl,agentEl){
|
|
139
403
|
if(isChatBusy()) return;
|
|
140
404
|
promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
|
|
141
|
-
const
|
|
405
|
+
const raw=promptEl.value.trim(); if(!raw) return;
|
|
142
406
|
const agent=agentEl.value;
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
145
411
|
}
|
|
146
412
|
async function doOrchestrate(promptEl){
|
|
147
413
|
if(isChatBusy()) return;
|
|
148
414
|
promptEl=promptEl||$('#runPrompt');
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
152
476
|
}
|
|
153
477
|
async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
|
|
154
478
|
// ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
|
|
@@ -178,11 +502,15 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
|
|
|
178
502
|
let sseBusy=false;
|
|
179
503
|
function updateChatBusyUI(){
|
|
180
504
|
const orchStatus=P&&P.runtime&&P.runtime.orchestration&&P.runtime.orchestration.status;
|
|
181
|
-
const busy=sseBusy||orchStatus==='running';
|
|
505
|
+
const busy=sseBusy||orchStatus==='running'||OFFLINE;
|
|
182
506
|
document.body.classList.toggle('chat-busy',!!busy);
|
|
183
|
-
|
|
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; });
|
|
184
511
|
const tabStatus=$('#tabChatStatus'); if(tabStatus) tabStatus.hidden=!busy;
|
|
185
512
|
const widgetStatus=$('#widgetChatStatus'); if(widgetStatus) widgetStatus.hidden=!busy;
|
|
513
|
+
const meetingStatus=$('#meetingRunStatus'); if(meetingStatus) meetingStatus.hidden=!busy;
|
|
186
514
|
}
|
|
187
515
|
|
|
188
516
|
function render(){
|
|
@@ -203,10 +531,12 @@ function render(){
|
|
|
203
531
|
if(meterFill) meterFill.style.width=(s.pct||0)+'%';
|
|
204
532
|
const meter=$('#globalMeter');
|
|
205
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
|
|
206
535
|
renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
|
|
207
536
|
renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
|
|
208
|
-
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden(); updateChatBusyUI();
|
|
537
|
+
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); renderNotes(); renderMeeting(); applySideHidden(); updateChatBusyUI();
|
|
209
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)
|
|
210
540
|
applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
|
|
211
541
|
applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
|
|
212
542
|
}
|
|
@@ -419,7 +749,8 @@ function renderBoard(){
|
|
|
419
749
|
running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
|
|
420
750
|
|
|
421
751
|
const board=$('#board'); board.innerHTML='';
|
|
422
|
-
updateBoardViewToggle();
|
|
752
|
+
updateBoardViewToggle(); updateKanbanPageSizeToggle();
|
|
753
|
+
if(kanbanColsOpen) renderKanbanColsPop(); // keep an open popover's checkbox state fresh across a live reload
|
|
423
754
|
board.classList.toggle('is-kanban', boardView==='kanban');
|
|
424
755
|
if(!tasks.length){ board.append(emptyState()); return; }
|
|
425
756
|
if(boardView==='kanban'){ renderKanban(board, tasks); return; } // columns by status
|
|
@@ -433,12 +764,15 @@ function renderBoard(){
|
|
|
433
764
|
if(!shown) board.append(noMatchState());
|
|
434
765
|
updatePhaseToggleAll();
|
|
435
766
|
}
|
|
436
|
-
// 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.
|
|
437
771
|
function renderKanban(board, tasks){
|
|
438
772
|
const q=filter.q.trim().toLowerCase();
|
|
439
773
|
const match=(t)=> !q || (t.title+' '+t.id).toLowerCase().includes(q);
|
|
440
774
|
const cols=el('div','kanban');
|
|
441
|
-
Object.keys(STATUS).forEach(st=>{
|
|
775
|
+
Object.keys(STATUS).filter(st=> kanbanColumns.includes(st)).forEach(st=>{
|
|
442
776
|
const colTasks=tasks.filter(t=> t.status===st && match(t));
|
|
443
777
|
const col=el('div','kanban-col');
|
|
444
778
|
const head=el('div','kanban-col-head');
|
|
@@ -447,12 +781,23 @@ function renderKanban(board, tasks){
|
|
|
447
781
|
col.append(head);
|
|
448
782
|
const body=el('div','kanban-col-body');
|
|
449
783
|
if(!colTasks.length) body.append(el('div','kanban-empty','—'));
|
|
450
|
-
|
|
451
|
-
|
|
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);
|
|
452
796
|
});
|
|
453
797
|
board.append(cols);
|
|
454
798
|
}
|
|
455
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)); }
|
|
456
801
|
function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
|
|
457
802
|
function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
|
|
458
803
|
function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
|
|
@@ -541,13 +886,11 @@ function backlogRow(r){
|
|
|
541
886
|
|
|
542
887
|
// ---- phase expand state: we track which phases are EXPANDED (default = none), so on a big
|
|
543
888
|
// project the board opens compact — just phase headers with progress — and the user opens what
|
|
544
|
-
// they need. Persisted per phase title (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
|
|
550
|
-
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();
|
|
551
894
|
function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
|
|
552
895
|
function updatePhaseToggleAll(){
|
|
553
896
|
const btn=$('#phaseToggleAll'); if(!btn) return;
|
|
@@ -866,10 +1209,13 @@ async function saveAgent(id){
|
|
|
866
1209
|
const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
|
|
867
1210
|
if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
|
|
868
1211
|
}
|
|
869
|
-
// ---- 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). ----
|
|
870
1216
|
function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
|
|
871
|
-
function applyDesign(id){ document.documentElement.setAttribute('data-design',id);
|
|
872
|
-
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}); }
|
|
873
1219
|
|
|
874
1220
|
function renderSettings(){
|
|
875
1221
|
const c=(P&&P.config)||{};
|
|
@@ -881,10 +1227,9 @@ function renderSettings(){
|
|
|
881
1227
|
if(dsel){
|
|
882
1228
|
const designs=(typeof DESIGNS!=='undefined')?DESIGNS:[{id:'control-room',name:'Control Room'}];
|
|
883
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); }); }
|
|
884
|
-
// reconcile
|
|
885
|
-
|
|
886
|
-
if(
|
|
887
|
-
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);
|
|
888
1233
|
dsel.value=currentDesign();
|
|
889
1234
|
}
|
|
890
1235
|
const box=$('#settingsReadonly');
|
|
@@ -896,6 +1241,8 @@ function renderSettings(){
|
|
|
896
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); });
|
|
897
1242
|
}
|
|
898
1243
|
const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
|
|
1244
|
+
renderNavTabsSettings();
|
|
1245
|
+
renderCommandsSettings();
|
|
899
1246
|
renderCustomize();
|
|
900
1247
|
}
|
|
901
1248
|
async function saveSettings(){
|
|
@@ -906,12 +1253,12 @@ async function saveSettings(){
|
|
|
906
1253
|
}
|
|
907
1254
|
|
|
908
1255
|
// ---- Custom dashboards (Customize page → generate-dashboard skill) ---------------------------
|
|
909
|
-
// A custom dashboard is a DECLARATIVE block spec (.spectoflow/
|
|
1256
|
+
// A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboards/<id>.json, embedded
|
|
910
1257
|
// in P.customDashboards by the server) — never raw HTML/CSS/JS. Every block below reuses the exact
|
|
911
1258
|
// same components the built-in Board renders with (kpiCard/ocard/bars/donut/statTile/mdLite/el), so a
|
|
912
1259
|
// generated dashboard automatically matches the active design — and any design switched to later —
|
|
913
|
-
// with zero page-specific styling.
|
|
914
|
-
// Node-side validator); this is the independent browser-side reader for the same shape.
|
|
1260
|
+
// with zero page-specific styling. The block schema is enforced by `spectoflow dashboard validate`
|
|
1261
|
+
// (Node-side validator); this is the independent browser-side reader for the same shape.
|
|
915
1262
|
function resolveBind(s,bindPath,fallback){
|
|
916
1263
|
if(bindPath==null) return fallback;
|
|
917
1264
|
let v=s;
|
|
@@ -1078,7 +1425,7 @@ async function czSubmit(kind,description,agent){
|
|
|
1078
1425
|
// A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
|
|
1079
1426
|
// /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
|
|
1080
1427
|
// by a dedicated branch in tabFromPath()/navigateTab() instead.
|
|
1081
|
-
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'];
|
|
1082
1429
|
// the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
|
|
1083
1430
|
// under that name still land on the Personalize tab instead of a blank panel.
|
|
1084
1431
|
function normalizeTab(t){ return t==='settings'?'personalize':t; }
|
|
@@ -1091,7 +1438,9 @@ function tabFromPath(){
|
|
|
1091
1438
|
function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
|
|
1092
1439
|
function navigateTab(tabId,push){
|
|
1093
1440
|
closeWfPop(); // an open popover is anchored to whichever panel is currently active — never carry it across
|
|
1094
|
-
|
|
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});
|
|
1095
1444
|
if(push!==false){
|
|
1096
1445
|
const isCustom=tabId.indexOf('custom:')===0;
|
|
1097
1446
|
history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
|
|
@@ -1419,80 +1768,42 @@ function renderFilesTree(){
|
|
|
1419
1768
|
}
|
|
1420
1769
|
function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
|
|
1421
1770
|
|
|
1422
|
-
// ----
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
const FILES_HL_LANG = {
|
|
1427
|
-
js: { comments:[['//','\n'],['/*','*/']], strings:['"',"'",'`'],
|
|
1428
|
-
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(' ') },
|
|
1429
|
-
json: { comments:[], strings:['"'], keywords:'true false null'.split(' ') },
|
|
1430
|
-
css: { comments:[['/*','*/']], strings:['"',"'"], keywords:[] },
|
|
1431
|
-
html: { comments:[['<!--','-->']], strings:['"',"'"], keywords:[], tags:true },
|
|
1432
|
-
py: { comments:[['#','\n']], strings:['"',"'"],
|
|
1433
|
-
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(' ') },
|
|
1434
|
-
sh: { comments:[['#','\n']], strings:['"',"'"],
|
|
1435
|
-
keywords:'if then else elif fi for while do done case esac function return exit export local readonly'.split(' ') },
|
|
1436
|
-
yml: { comments:[['#','\n']], strings:['"',"'"], keywords:'true false null'.split(' ') },
|
|
1437
|
-
};
|
|
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. ----
|
|
1438
1775
|
function filesHlLang(ext){
|
|
1439
|
-
if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return '
|
|
1776
|
+
if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'javascript';
|
|
1440
1777
|
if(ext==='json') return 'json';
|
|
1441
1778
|
if(ext==='css') return 'css';
|
|
1442
|
-
if(['html','htm'].includes(ext)) return '
|
|
1443
|
-
if(ext==='py') return '
|
|
1444
|
-
if(['sh','bash'].includes(ext)) return '
|
|
1445
|
-
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';
|
|
1446
1796
|
return null;
|
|
1447
1797
|
}
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
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.
|
|
1451
1802
|
function filesHighlight(src,langKey){
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const n=src.length;
|
|
1455
|
-
let i=0,html='',plain='';
|
|
1456
|
-
const flushPlain=()=>{ if(plain){ html+=escHtml(plain); plain=''; } };
|
|
1457
|
-
const isWordChar=c=>/[A-Za-z0-9_$]/.test(c);
|
|
1458
|
-
while(i<n){
|
|
1459
|
-
let matched=false;
|
|
1460
|
-
// comments
|
|
1461
|
-
for(const [open,close] of lang.comments){
|
|
1462
|
-
if(src.startsWith(open,i)){
|
|
1463
|
-
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);
|
|
1464
|
-
flushPlain(); html+='<span class="hl-comment">'+escHtml(src.slice(i,end))+'</span>'; i=end; matched=true; break;
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
if(matched) continue;
|
|
1468
|
-
// strings
|
|
1469
|
-
if(lang.strings.includes(src[i])){
|
|
1470
|
-
const q=src[i]; let j=i+1;
|
|
1471
|
-
while(j<n && src[j]!==q){ if(src[j]==='\\') j++; j++; }
|
|
1472
|
-
j=Math.min(j+1,n);
|
|
1473
|
-
flushPlain(); html+='<span class="hl-string">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1474
|
-
}
|
|
1475
|
-
// html tags (bonus: <tag ...> / </tag>) — a light touch, not full attribute-vs-value parsing
|
|
1476
|
-
if(lang.tags && src[i]==='<' && /[a-zA-Z/!]/.test(src[i+1]||'')){
|
|
1477
|
-
const end=src.indexOf('>',i); const j=end===-1?n:end+1;
|
|
1478
|
-
flushPlain(); html+='<span class="hl-tag">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1479
|
-
}
|
|
1480
|
-
// numbers
|
|
1481
|
-
if(/[0-9]/.test(src[i]) && !isWordChar(src[i-1]||'')){
|
|
1482
|
-
let j=i; while(j<n && /[0-9.]/.test(src[j])) j++;
|
|
1483
|
-
flushPlain(); html+='<span class="hl-number">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1484
|
-
}
|
|
1485
|
-
// keywords
|
|
1486
|
-
if(isWordChar(src[i]) && !isWordChar(src[i-1]||'')){
|
|
1487
|
-
let j=i; while(j<n && isWordChar(src[j])) j++;
|
|
1488
|
-
const word=src.slice(i,j);
|
|
1489
|
-
if(lang.keywords.includes(word)){ flushPlain(); html+='<span class="hl-keyword">'+escHtml(word)+'</span>'; i=j; continue; }
|
|
1490
|
-
plain+=word; i=j; continue;
|
|
1491
|
-
}
|
|
1492
|
-
plain+=src[i]; i++;
|
|
1803
|
+
if(langKey && window.Prism && Prism.languages && Prism.languages[langKey]){
|
|
1804
|
+
return Prism.highlight(src, Prism.languages[langKey], langKey);
|
|
1493
1805
|
}
|
|
1494
|
-
|
|
1495
|
-
return html;
|
|
1806
|
+
return escHtml(src);
|
|
1496
1807
|
}
|
|
1497
1808
|
// A textarea can't render colored text itself, so this overlays one, transparent, on top of a
|
|
1498
1809
|
// highlighted <pre><code> "backdrop" showing through it (the standard technique for a highlighted
|
|
@@ -1656,6 +1967,208 @@ async function submitFilesCreate(){
|
|
|
1656
1967
|
if(kind!=='dir') openFilesFile(rel);
|
|
1657
1968
|
}
|
|
1658
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
|
+
|
|
1659
2172
|
function openDrawer(id,keep){
|
|
1660
2173
|
// named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
|
|
1661
2174
|
// function calls it repeatedly below; shadowing it with a task variable would break every call.
|
|
@@ -1702,8 +2215,9 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
|
|
|
1702
2215
|
// and render()'s SSE-driven re-render (triggered by the snapshot write / polling) re-applies it
|
|
1703
2216
|
// too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
|
|
1704
2217
|
// 'change'/'message' event that lands right after a click.
|
|
1705
|
-
// initial tab: the URL path wins (deep-link / refresh), else the persisted tab
|
|
1706
|
-
|
|
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';
|
|
1707
2221
|
openTaskId = taskFromPath(); // deep-link straight to a task drawer
|
|
1708
2222
|
function applyActiveTab(){
|
|
1709
2223
|
$$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
|
|
@@ -1748,18 +2262,104 @@ applyActiveTab(); // sync to the resolved tab before the first render
|
|
|
1748
2262
|
if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
|
|
1749
2263
|
// filters (status chips + search) — client-side only, does not write anything
|
|
1750
2264
|
$$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
|
|
1751
|
-
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
|
|
2265
|
+
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; kanbanExpanded.clear(); renderBoard(); });
|
|
1752
2266
|
// board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
|
|
1753
|
-
$$('#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
|
+
}));
|
|
1754
2346
|
// right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
|
|
1755
2347
|
// persisted per viewer like every other layout preference here
|
|
1756
2348
|
function applySideHidden(){
|
|
1757
|
-
const panel=$('.panel[data-panel="board"]');
|
|
2349
|
+
const panel=$('.panel[data-panel="board"]');
|
|
1758
2350
|
if(panel) panel.classList.toggle('side-hidden', sideHidden);
|
|
1759
|
-
|
|
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
|
+
}
|
|
1760
2356
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
2357
|
+
function toggleSideHidden(){
|
|
2358
|
+
sideHidden=!sideHidden;
|
|
2359
|
+
saveSetting({sideHidden});
|
|
2360
|
+
applySideHidden();
|
|
2361
|
+
}
|
|
2362
|
+
for(const btn of $$('#sideToggle, #sideToggleInline')) btn.addEventListener('click', toggleSideHidden);
|
|
1763
2363
|
applySideHidden();
|
|
1764
2364
|
// expand / collapse all phases (List view) — keeps a big board compact by default
|
|
1765
2365
|
const phaseToggleAllBtn=$('#phaseToggleAll');
|
|
@@ -1814,9 +2414,11 @@ const topAgentSel=$('#topAgent'); if(topAgentSel) topAgentSel.addEventListener('
|
|
|
1814
2414
|
const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('change',(e)=>saveAgent(e.target.value));
|
|
1815
2415
|
// hub-mode pill — only ever shown when this tab is actually served via the multi-project hub
|
|
1816
2416
|
(function(){ const p=$('#hubPill'); if(p && PROJECT_ID) p.hidden=false; })();
|
|
1817
|
-
// theme
|
|
1818
|
-
(
|
|
1819
|
-
|
|
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
|
+
})();
|
|
1820
2422
|
// chart tooltip — a single floating layer, shown on hover over any [data-tip]
|
|
1821
2423
|
// element (area-hit rects, future hit targets) and positioned near the cursor.
|
|
1822
2424
|
(function(){
|
|
@@ -1838,10 +2440,14 @@ const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('
|
|
|
1838
2440
|
$$('[data-icon]').forEach(node=>{ const svg=ICON[node.dataset.icon]; if(svg) node.innerHTML=svg; });
|
|
1839
2441
|
})();
|
|
1840
2442
|
// chat widget
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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.
|
|
1845
2451
|
$('#runBtn').addEventListener('click',()=>doRun());
|
|
1846
2452
|
$('#orchBtn').addEventListener('click',()=>doOrchestrate());
|
|
1847
2453
|
$('#widgetSummarizeBtn').addEventListener('click',()=>summarizeChat($('#runAgent')));
|
|
@@ -1852,7 +2458,20 @@ $('#tabRunBtn').addEventListener('click',()=>doRun($('#tabRunPrompt'),$('#tabRun
|
|
|
1852
2458
|
$('#tabOrchBtn').addEventListener('click',()=>doOrchestrate($('#tabRunPrompt')));
|
|
1853
2459
|
$('#tabSummarizeBtn').addEventListener('click',()=>summarizeChat($('#tabRunAgent')));
|
|
1854
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);
|
|
1855
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); });
|
|
1856
2475
|
$('#drawerClose').addEventListener('click',closeDrawer);
|
|
1857
2476
|
$('#drawerScrim').addEventListener('click',closeDrawer);
|
|
1858
2477
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDrawer(); });
|