@polderlabs/openkan 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,2397 @@
1
+ // OpenKan — chat sidebar (right-rail chat orchestrator).
2
+ //
3
+ // Mounts a fixed-position aside on the right edge of the viewport with:
4
+ // - A hero title "What should we work on?" shown only on empty sessions.
5
+ // - A scrollable transcript that renders each turn as a single bubble
6
+ // (right-aligned coral pill for user; plain left-aligned text for
7
+ // assistant / system) with compact tool-use chips between turns.
8
+ // While the assistant is streaming, a muted italic "Working" line
9
+ // sits below the bubble and disappears when the turn completes.
10
+ // - A composer footer: single rounded bar with attach / textarea /
11
+ // model pill / mic / send (or abort while streaming) inline.
12
+ // - A tabs row: Project / Files / Plugins / Get desktop app (link).
13
+ // Activity footer still exists as a slide-in section but is no
14
+ // longer a tab.
15
+ // - Cmd/Ctrl+K focuses the composer when the sidebar is open.
16
+ //
17
+ // Persistence: the last-selected session id and selector state are written
18
+ // to `localStorage` under `ok.chat.*` keys and restored on mount.
19
+ //
20
+ // Public API: window.OpenKanChatSidebar = { mount, unmount, toggle, open,
21
+ // close, isOpen }.
22
+ //
23
+ (() => {
24
+ "use strict";
25
+
26
+ /* ----------------------------------------------------------------------
27
+ * Constants & helpers
28
+ * -------------------------------------------------------------------- */
29
+ const STORAGE_KEYS = {
30
+ lastSession: "ok.chat.lastSession",
31
+ open: "ok.chat.open",
32
+ selectors: "ok.chat.selectors",
33
+ width: "ok.chat.width",
34
+ draft: "ok.chat.draft",
35
+ };
36
+ const DEFAULT_SELECTORS = Object.freeze({
37
+ agent: "openkan",
38
+ model: "default",
39
+ effort: "high",
40
+ permissionMode: "bypassPermissions",
41
+ });
42
+ const EFFORT_OPTIONS = ["low", "medium", "high", "max"];
43
+ const PERMISSION_OPTIONS = [
44
+ "bypassPermissions", "acceptEdits", "auto", "manual", "dontAsk", "plan",
45
+ ];
46
+
47
+ function esc(v) {
48
+ return String(v ?? "")
49
+ .replaceAll("&", "&")
50
+ .replaceAll("<", "&lt;")
51
+ .replaceAll(">", "&gt;")
52
+ .replaceAll('"', "&quot;")
53
+ .replaceAll("'", "&#39;");
54
+ }
55
+
56
+ function loadJSON(key) {
57
+ try {
58
+ const raw = localStorage.getItem(key);
59
+ return raw ? JSON.parse(raw) : null;
60
+ } catch (_err) { return null; }
61
+ }
62
+ function saveJSON(key, value) {
63
+ try { localStorage.setItem(key, JSON.stringify(value)); }
64
+ catch (_err) { /* quota / disabled — ignore */ }
65
+ }
66
+ function loadString(key) {
67
+ try { return localStorage.getItem(key) || ""; } catch (_err) { return ""; }
68
+ }
69
+ function saveString(key, value) {
70
+ try { localStorage.setItem(key, value); } catch (_err) { /* ignore */ }
71
+ }
72
+
73
+ // Session selection and chat selectors belong to a workspace, not the
74
+ // browser globally. The server is authoritative for transcript storage;
75
+ // this only remembers the last session to reopen for each project.
76
+ function projectStorageKey(key) {
77
+ return `${key}:${state.projectScope || "workspace"}`;
78
+ }
79
+
80
+ async function resolveProjectScope() {
81
+ const a = api();
82
+ if (!a) return "workspace";
83
+ try {
84
+ const data = await a("GET", "/api/project");
85
+ const active = data?.active;
86
+ const identity = active?.id || active?.root;
87
+ return identity ? encodeURIComponent(String(identity)) : "workspace";
88
+ } catch (_err) {
89
+ return "workspace";
90
+ }
91
+ }
92
+
93
+ function relativeTime(iso) {
94
+ if (!iso) return "";
95
+ const then = new Date(iso).getTime();
96
+ if (Number.isNaN(then)) return "";
97
+ const diffMs = Date.now() - then;
98
+ if (diffMs < 1000) return "just now";
99
+ const sec = Math.floor(diffMs / 1000);
100
+ if (sec < 60) return `${sec}s ago`;
101
+ const min = Math.floor(sec / 60);
102
+ if (min < 60) return `${min}m ago`;
103
+ const hr = Math.floor(min / 60);
104
+ if (hr < 24) return `${hr}h ago`;
105
+ const day = Math.floor(hr / 24);
106
+ if (day < 7) return `${day}d ago`;
107
+ return new Date(iso).toLocaleDateString();
108
+ }
109
+
110
+ function basename(p) {
111
+ if (!p) return "";
112
+ const norm = String(p).replace(/\\/g, "/");
113
+ const idx = norm.lastIndexOf("/");
114
+ return idx === -1 ? norm : norm.slice(idx + 1);
115
+ }
116
+
117
+ function truncate(s, max) {
118
+ if (!s) return "";
119
+ if (s.length <= max) return s;
120
+ return s.slice(0, Math.max(0, max - 1)) + "…";
121
+ }
122
+
123
+ // ToolUseRecord -> human label. Mirrors `toolUseLabel` on the server so
124
+ // chips render identically whether streamed live or replayed from JSONL.
125
+ function toolUseLabel(tool) {
126
+ const input = (tool && tool.input) || {};
127
+ const file = typeof input.file_path === "string" ? input.file_path : "";
128
+ const cmd = typeof input.command === "string" ? input.command : "";
129
+ const q = typeof input.query === "string" ? input.query
130
+ : typeof input.pattern === "string" ? input.pattern : "";
131
+ const url = typeof input.url === "string" ? input.url : "";
132
+ const sub = typeof input.subagent_type === "string" ? input.subagent_type : "";
133
+ switch (tool.name) {
134
+ case "Read": return `Reading ${basename(file) || "file"}`;
135
+ case "Write": return `Writing ${basename(file) || "file"}`;
136
+ case "Edit": return `Editing ${basename(file) || "file"}`;
137
+ case "Bash": return `Running ${truncate(cmd.replace(/\s+/g, " ").trim(), 60)}`;
138
+ case "Grep": return `Searching for "${truncate(q, 40)}"`;
139
+ case "Glob": return `Finding ${truncate(typeof input.pattern === "string" ? input.pattern : "", 60)}`;
140
+ case "WebFetch": return `Fetching ${truncate(url, 60)}`;
141
+ case "WebSearch": return `Searching the web for "${truncate(q, 40)}"`;
142
+ case "Agent":
143
+ case "Task": return `Delegating to ${sub || "subagent"}`;
144
+ default: return `Using ${tool.name}`;
145
+ }
146
+ }
147
+
148
+ /* ----------------------------------------------------------------------
149
+ * Module state
150
+ * -------------------------------------------------------------------- */
151
+ const state = {
152
+ mounted: false,
153
+ requestEpoch: 0,
154
+ dismissController: null,
155
+ open: false,
156
+ root: null,
157
+ sessions: [],
158
+ currentSessionId: "",
159
+ selectors: { ...DEFAULT_SELECTORS },
160
+ transcript: [], // ChatTurn[]
161
+ models: [],
162
+ inFlight: false,
163
+ activityOpen: false,
164
+ sse: null,
165
+ abortController: null,
166
+ renderedCache: new Map(),
167
+ // True when the user has scrolled up and we suppressed auto-scroll.
168
+ scrolledUp: false,
169
+ // True when the live stream is appending tokens into the bubble — used
170
+ // to skip re-rendering the bubble per token.
171
+ liveBubble: null,
172
+ liveChips: null,
173
+ liveActivity: [],
174
+ // Task references staged in the composer by board drag-and-drop.
175
+ taskMentions: new Map(),
176
+ // Completion marks are allowed one entrance only; rendered history stays still.
177
+ completedMotionTs: new Set(),
178
+ // Cached `/api/chat/picker-options` payload (model list + efforts + perms).
179
+ pickerOptions: null,
180
+ // Currently-open popover id (or null). Only one popover at a time.
181
+ popoverId: null,
182
+ // Currently-open tab name (project / files / plugins / activity). null
183
+ // when no tab is active. Activity uses activityOpen instead of a popover.
184
+ activeTab: null,
185
+ width: 460,
186
+ resizing: false,
187
+ // Set before reading session-specific localStorage values in mount().
188
+ projectScope: "workspace",
189
+ };
190
+
191
+ /* ----------------------------------------------------------------------
192
+ * Network helpers
193
+ * -------------------------------------------------------------------- */
194
+ function api() { return window.OpenKanAPI?.api; }
195
+ async function fetchSessions() {
196
+ const a = api();
197
+ if (!a) return [];
198
+ try {
199
+ const data = await a("GET", "/api/chat/sessions");
200
+ return Array.isArray(data?.sessions) ? data.sessions : [];
201
+ } catch (_err) { return []; }
202
+ }
203
+ async function fetchSession(id) {
204
+ const a = api();
205
+ if (!a || !id) return null;
206
+ try {
207
+ return await a("GET", `/api/chat/sessions/${encodeURIComponent(id)}`);
208
+ } catch (_err) { return null; }
209
+ }
210
+ async function fetchModels() {
211
+ const a = api();
212
+ if (!a) return [];
213
+ try {
214
+ const data = await a("GET", "/api/claude/model-router");
215
+ if (Array.isArray(data?.models)) return data.models.map((m) => typeof m === "string" ? m : (m.id || m.name || "")).filter(Boolean);
216
+ if (Array.isArray(data)) return data.map((m) => typeof m === "string" ? m : (m.id || m.name || "")).filter(Boolean);
217
+ return [];
218
+ } catch (_err) { return []; }
219
+ }
220
+ async function abortSession(sessionId) {
221
+ const a = api();
222
+ if (!a || !sessionId) return;
223
+ try { await a("POST", `/api/chat/sessions/${encodeURIComponent(sessionId)}/abort`); }
224
+ catch (_err) { /* swallow */ }
225
+ }
226
+ async function deleteSession(sessionId) {
227
+ const a = api();
228
+ if (!a || !sessionId) return;
229
+ try { await a("DELETE", `/api/chat/sessions/${encodeURIComponent(sessionId)}`); }
230
+ catch (_err) { /* swallow */ }
231
+ }
232
+ async function renderMarkdown(text) {
233
+ if (!text) return "";
234
+ if (state.renderedCache.has(text)) return state.renderedCache.get(text);
235
+ const a = api();
236
+ if (!a) return esc(text);
237
+ try {
238
+ const html = await a("POST", "/api/chat/render-markdown", { markdown: text });
239
+ state.renderedCache.set(text, html);
240
+ return html;
241
+ } catch (_err) {
242
+ return `<pre>${esc(text)}</pre>`;
243
+ }
244
+ }
245
+
246
+ // Debounced incremental markdown render while a chat stream is in flight.
247
+ // The server-side /api/chat/render-markdown endpoint returns sanitized
248
+ // HTML, so it can be assigned to innerHTML directly. Each flush captures
249
+ // the text snapshot at debounce-fire time (not at schedule time) so we
250
+ // render the latest accumulated content. A monotonically-increasing
251
+ // token guards against late responses overwriting a newer render or the
252
+ // final render produced by finalizeLiveBubble().
253
+ const STREAM_RENDER_DEBOUNCE_MS = 120;
254
+ let streamRenderTimer = null;
255
+ let streamRenderToken = 0;
256
+ function scheduleStreamRender(bubble) {
257
+ if (!bubble) return;
258
+ if (streamRenderTimer) clearTimeout(streamRenderTimer);
259
+ const token = ++streamRenderToken;
260
+ streamRenderTimer = setTimeout(() => {
261
+ streamRenderTimer = null;
262
+ // Snapshot at flush time — never re-read after the await.
263
+ const snapshot = bubble.textContent || "";
264
+ if (!snapshot) return;
265
+ const renderToken = token;
266
+ renderMarkdown(snapshot).then((html) => {
267
+ // Drop the result if a newer flush already ran, the stream
268
+ // finalised while we were awaiting, or the bubble is no longer the
269
+ // live bubble in the DOM.
270
+ if (renderToken !== streamRenderToken) return;
271
+ if (!bubble.isConnected) return;
272
+ if (bubble.dataset.streaming !== "1") return;
273
+ bubble.innerHTML = html || esc(snapshot);
274
+ }).catch(() => { /* ignored — next flush or final render will recover */ });
275
+ }, STREAM_RENDER_DEBOUNCE_MS);
276
+ }
277
+ function cancelStreamRender() {
278
+ if (streamRenderTimer) { clearTimeout(streamRenderTimer); streamRenderTimer = null; }
279
+ streamRenderToken++;
280
+ }
281
+
282
+ /* ----------------------------------------------------------------------
283
+ * DOM construction
284
+ *
285
+ * Layout:
286
+ * <aside>
287
+ * <button handle>
288
+ * <header> (session selector only + actions)
289
+ * <section bubbles> (scrollable transcript of bubbles + chips)
290
+ * <footer composer> (textarea + inline pill selectors + send)
291
+ * <section activity>(hidden claude-pane mount)
292
+ * </aside>
293
+ * -------------------------------------------------------------------- */
294
+ function buildShell() {
295
+ const aside = document.createElement("aside");
296
+ aside.id = "chat-sidebar";
297
+ aside.className = "chat-sidebar";
298
+ aside.setAttribute("aria-label", "Chat orchestrator");
299
+ aside.setAttribute("role", "complementary");
300
+ aside.hidden = true;
301
+ aside.innerHTML = `
302
+ <div class="chat-sidebar__resize-handle" data-chat-resize role="separator" aria-orientation="vertical"
303
+ aria-label="Resize chat sidebar" aria-valuemin="320" aria-valuemax="640" aria-valuenow="460" tabindex="0"></div>
304
+
305
+ <!-- Hidden selectors. The legacy visible chip / header sub-sections
306
+ have been removed; the <select>s are still mounted (hidden) so
307
+ populateSelectors() and sendTurn() can read/write session +
308
+ model + effort + permission state. -->
309
+ <div hidden>
310
+ <span class="chat-sidebar-meta" id="chat-sidebar-meta">—</span>
311
+ <select id="chat-select-session" data-chat-select="session"></select>
312
+ <select id="chat-select-model" data-chat-select="model"></select>
313
+ <select id="chat-select-effort" data-chat-select="effort"></select>
314
+ <select id="chat-select-permission" data-chat-select="permissionMode"></select>
315
+ </div>
316
+
317
+ <header class="chat-sidebar__topbar">
318
+ <button type="button" class="chat-sidebar__session-chip" data-chat-action="open-session-menu"
319
+ aria-haspopup="true" aria-expanded="false" title="Switch session">
320
+ <span class="chat-sidebar__session-kicker">Chat</span>
321
+ <span class="chat-sidebar__session-chip-title" id="chat-sidebar-session-title">New chat</span>
322
+ <svg class="chat-sidebar__session-chip-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
323
+ <path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
324
+ </svg>
325
+ </button>
326
+ <button type="button" class="chat-sidebar__new-chat" data-chat-action="new" aria-label="Start a new chat" title="New chat">
327
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
328
+ <path d="M8 2.25v11.5M2.25 8h11.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
329
+ </svg>
330
+ <span>New chat</span>
331
+ </button>
332
+ </header>
333
+
334
+ <div class="chat-sidebar__workspace-tools" aria-label="Chat shortcuts">
335
+ <span class="chat-sidebar__workspace-label">Workspace assistant</span>
336
+ <div class="chat-sidebar__quick-prompts" role="group" aria-label="Suggested prompts">
337
+ <button type="button" data-chat-prompt="Plan the next best task for this project.">Plan next task</button>
338
+ <button type="button" data-chat-prompt="Summarize the current project state and blockers.">Summarize</button>
339
+ <button type="button" data-chat-prompt="Review the current changes and identify risks.">Review changes</button>
340
+ <button type="button" data-chat-prompt="What should I work on next?">Suggest work</button>
341
+ </div>
342
+ </div>
343
+
344
+ <div class="chat-sidebar__hero chat-sidebar-hero" id="chat-sidebar-hero">
345
+ <div class="chat-sidebar__hero-mark" aria-hidden="true">
346
+ <svg width="22" height="22" viewBox="0 0 24 24">
347
+ <path d="M5 5.5h14v10.75H9.25L5 20.5V5.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
348
+ <path d="M8.5 10h7M8.5 13.5h4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
349
+ </svg>
350
+ </div>
351
+ <h2 class="chat-sidebar__hero-title">What should we work on?</h2>
352
+ <p class="chat-sidebar__hero-copy">Ask about this project, plan a task, or start a focused coding session.</p>
353
+ </div>
354
+
355
+ <section class="chat-sidebar__transcript chat-sidebar-transcript" id="chat-sidebar-transcript"
356
+ aria-label="Chat transcript" tabindex="0"></section>
357
+ <button type="button" class="chat-sidebar-new-messages" id="chat-sidebar-new-messages"
358
+ hidden>Jump to latest ↓</button>
359
+
360
+ <footer class="chat-sidebar__composer chat-sidebar-composer">
361
+ <div class="chat-sidebar__composer-surface">
362
+ <div class="chat-sidebar__mention-tray" id="chat-sidebar-mention-tray" hidden aria-live="polite" aria-label="Task references"></div>
363
+ <textarea id="chat-sidebar-input" class="chat-sidebar__composer-input"
364
+ rows="1" placeholder="Ask about this project…" aria-label="Message OpenKan" aria-describedby="chat-sidebar-input-help"></textarea>
365
+ <div class="chat-sidebar__composer-footer">
366
+ <button type="button" class="chat-sidebar__composer-agent" data-chat-action="open-agent-picker"
367
+ aria-label="Choose agent" aria-haspopup="true" aria-expanded="false" title="Choose an agent for this conversation">
368
+ <span id="chat-sidebar-agent-label">OpenKan</span>
369
+ <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m2 4 4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
370
+ </button>
371
+ <button type="button" class="chat-sidebar__composer-attach" data-chat-action="open-attach-menu"
372
+ aria-label="Add context or start a new chat" aria-haspopup="true" aria-expanded="false" title="Add context">
373
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
374
+ <path d="M9 2.25v13.5M2.25 9h13.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
375
+ </svg>
376
+ </button>
377
+ <button type="button" class="chat-sidebar__composer-model" data-chat-action="open-model-picker"
378
+ aria-label="Choose model" aria-haspopup="true" aria-expanded="false" title="Choose model">
379
+ <span class="chat-sidebar__composer-model-label" id="chat-sidebar-model-label">Default</span>
380
+ <svg class="chat-sidebar__chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
381
+ <path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
382
+ </svg>
383
+ </button>
384
+ <button type="button" class="chat-sidebar__composer-model chat-sidebar__composer-effort" data-chat-action="open-effort-picker"
385
+ aria-label="Choose reasoning effort" aria-haspopup="true" aria-expanded="false" title="Reasoning effort">
386
+ <span class="chat-sidebar__composer-model-label" id="chat-sidebar-effort-label">High effort</span>
387
+ <svg class="chat-sidebar__chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
388
+ </button>
389
+ <span class="chat-sidebar__send-hint" aria-hidden="true">Enter to send</span>
390
+ <button type="button" id="chat-sidebar-send" class="chat-sidebar__composer-send chat-send"
391
+ data-chat-action="send" aria-label="Send message" title="Send message">
392
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
393
+ <path d="M9 14.75V3.25M4.75 7.5 9 3.25l4.25 4.25" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
394
+ </svg>
395
+ </button>
396
+ <button type="button" id="chat-sidebar-abort" class="chat-sidebar__composer-abort chat-abort"
397
+ data-chat-action="abort" hidden aria-label="Stop generating" title="Stop generating">
398
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><rect x="2" y="2" width="10" height="10" rx="1.5" fill="currentColor" /></svg>
399
+ </button>
400
+ </div>
401
+ </div>
402
+ <div class="chat-sidebar__input-help" id="chat-sidebar-input-help"><span>Shift + Enter for a new line</span><span>Drop a task to reference it</span></div>
403
+ <p class="chat-sidebar__feedback" id="chat-sidebar-feedback" role="status" hidden></p>
404
+ <p class="chat-sidebar__disclaimer" id="chat-sidebar-disclaimer">Review important changes before applying them.</p>
405
+ </footer>
406
+
407
+ <section class="chat-sidebar__activity chat-sidebar-activity" id="chat-sidebar-activity"
408
+ aria-label="Activity" hidden>
409
+ <div id="chat-sidebar-claude-root"></div>
410
+ </section>
411
+ <div id="chat-sidebar-popover-mount"></div>
412
+ `;
413
+ document.body.appendChild(aside);
414
+ return aside;
415
+ }
416
+
417
+ function populateSelectors() {
418
+ if (!state.root) return;
419
+ const sessionSel = state.root.querySelector("#chat-select-session");
420
+ const modelSel = state.root.querySelector("#chat-select-model");
421
+ const effortSel = state.root.querySelector("#chat-select-effort");
422
+ const permSel = state.root.querySelector("#chat-select-permission");
423
+
424
+ if (sessionSel) {
425
+ const opts = [`<option value="__new__">+ New session</option>`]
426
+ .concat(state.sessions.map((s) =>
427
+ `<option value="${esc(s.id)}">${esc(s.title || s.id)}</option>`,
428
+ ));
429
+ sessionSel.innerHTML = opts.join("");
430
+ sessionSel.value = state.currentSessionId || "__new__";
431
+ }
432
+
433
+ if (modelSel) {
434
+ const opts = state.models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("");
435
+ modelSel.innerHTML = opts || `<option value="default">default</option>`;
436
+ modelSel.value = state.selectors.model || "default";
437
+ }
438
+ if (effortSel) {
439
+ effortSel.innerHTML = EFFORT_OPTIONS.map((e) =>
440
+ `<option value="${e}">${e}</option>`).join("");
441
+ effortSel.value = state.selectors.effort || "high";
442
+ }
443
+ if (permSel) {
444
+ permSel.innerHTML = PERMISSION_OPTIONS.map((p) =>
445
+ `<option value="${p}">${p}</option>`).join("");
446
+ permSel.value = state.selectors.permissionMode || "default";
447
+ }
448
+
449
+ // Sync the visible chip + pill labels with the underlying <select>
450
+ // state. Hidden <select>s are still written above for back-compat with
451
+ // sendTurn() and persistence; the chip / pill are pure presentation.
452
+ updateSessionChip();
453
+ updateModelPill();
454
+ syncHeroState();
455
+ }
456
+
457
+ function updateSessionChip() {
458
+ if (!state.root) return;
459
+ const label = state.root.querySelector("#chat-sidebar-session-title");
460
+ if (!label) return;
461
+ const cur = (state.sessions || []).find((s) => s.id === state.currentSessionId);
462
+ label.textContent = cur?.title || cur?.id || "New chat";
463
+ }
464
+
465
+ function updateModelPill() {
466
+ if (!state.root) return;
467
+ const label = state.root.querySelector("#chat-sidebar-model-label");
468
+ if (!label) return;
469
+ const raw = state.selectors.model || "default";
470
+ label.textContent = raw === "default" ? "Default" : String(raw).replace(/^.*?\//, "");
471
+ const agentLabel = state.root.querySelector("#chat-sidebar-agent-label");
472
+ if (agentLabel) agentLabel.textContent = state.selectors.agent === "openkan" ? "OpenKan" : state.selectors.agent === "default" ? "Claude Code" : state.selectors.agent;
473
+ const effortLabel = state.root.querySelector("#chat-sidebar-effort-label");
474
+ if (effortLabel) effortLabel.textContent = `${String(state.selectors.effort || "high").replace(/^./, (c) => c.toUpperCase())} effort`;
475
+ }
476
+
477
+ /** Toggle the "What should we work on?" hero + ChatGPT-style disclaimer
478
+ * based on transcript state. The CSS rule
479
+ * .chat-sidebar--has-messages .chat-sidebar__disclaimer shows the
480
+ * disclaimer whenever the class is set, so no per-node hidden flag is
481
+ * needed. */
482
+ function syncHeroState() {
483
+ if (!state.root) return;
484
+ const has = Array.isArray(state.transcript) && state.transcript.length > 0;
485
+ state.root.classList.toggle("chat-sidebar--has-messages", has);
486
+ }
487
+
488
+ /* ----------------------------------------------------------------------
489
+ * Bubble rendering
490
+ * -------------------------------------------------------------------- */
491
+ function formatTurnTime(ts) {
492
+ const date = new Date(ts || "");
493
+ return Number.isNaN(date.getTime()) ? "Now" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
494
+ }
495
+ function bubbleMetaHTML(turn) {
496
+ const label = turn.role === "user" ? "You" : turn.role === "assistant" ? (turn.agent === "openkan" ? "OpenKan" : !turn.agent || turn.agent === "default" ? "Claude Code" : turn.agent) : "OpenKan";
497
+ const copy = turn.role !== "system"
498
+ ? `<button type="button" class="chat-copy-button" data-chat-copy="${esc(turn.ts || "")}" aria-label="Copy ${esc(label)} message" title="Copy message">⧉</button>`
499
+ : "";
500
+ return `<div class="chat-bubble-meta ${turn.role === "assistant" ? "chat-bubble-meta-assistant" : ""}">${turn.role === "assistant" ? copy : ""}<span>${esc(label)} · ${formatTurnTime(turn.ts)}</span>${turn.role !== "assistant" ? copy : ""}</div>`;
501
+ }
502
+ function bubbleHTML(turn) {
503
+ const role = turn.role || "assistant";
504
+ const errorLine = turn.error
505
+ ? `<div class="chat-bubble-error">${esc(turn.error)}</div>` : "";
506
+ if (role === "user") {
507
+ const status = turn.__status || (turn.status && turn.status !== "ok" ? turn.status : "sent");
508
+ return `
509
+ <div class="chat-bubble-row chat-bubble-row-user">
510
+ <div class="chat-bubble chat-bubble-user" data-ts="${esc(turn.ts || "")}" data-status="${esc(status)}">
511
+ ${bubbleMetaHTML(turn)}
512
+ ${taskReferenceBannersHTML(turn)}
513
+ ${turn.content ? `<div class="chat-bubble-body">${esc(turn.content)}</div>` : ""}
514
+ </div>
515
+ ${errorLine}
516
+ </div>
517
+ `;
518
+ }
519
+ if (role === "system") {
520
+ const status = turn.status || "ok";
521
+ return `
522
+ <div class="chat-bubble-row chat-bubble-row-system">
523
+ <div class="chat-bubble chat-bubble-system" data-ts="${esc(turn.ts || "")}" data-status="${esc(status)}">
524
+ ${bubbleMetaHTML(turn)}
525
+ <div class="chat-bubble-body">${esc(turn.content || "")}</div>
526
+ ${errorLine}
527
+ </div>
528
+ </div>
529
+ `;
530
+ }
531
+ // Assistant bubble. Body is filled by renderTranscript via async
532
+ // markdown rendering; the empty placeholder lets us stream into it
533
+ // incrementally without re-parsing markdown each tick. The
534
+ // [data-bubble-body] marker is the streaming hook used by appendToken
535
+ // and finalizeLiveBubble — keep it.
536
+ return `
537
+ <div class="chat-bubble-row chat-bubble-row-assistant">
538
+ <div class="chat-bubble chat-bubble-assistant" data-ts="${esc(turn.ts || "")}" data-status="${esc(turn.status || "ok")}">
539
+ ${bubbleMetaHTML(turn)}
540
+ <div class="chat-bubble-body chat-bubble-body-stream" data-bubble-body></div>
541
+ ${errorLine}
542
+ </div>
543
+ </div>
544
+ `;
545
+ }
546
+
547
+ function taskReferenceBannersHTML(turn) {
548
+ const tasks = Array.isArray(turn.taskMentions) ? turn.taskMentions : [];
549
+ if (!tasks.length) return "";
550
+ return `<div class="chat-task-reference-banners" aria-label="Referenced tasks">${tasks.map((task) => {
551
+ const id = typeof task?.id === "string" ? task.id : "task";
552
+ const title = typeof task?.title === "string" ? task.title : "";
553
+ return `<span class="chat-task-reference-banner" title="${esc(title || id)}"><span aria-hidden="true">↗</span> Task #${esc(id.replace(/^tsk-/, "").slice(0, 6))}</span>`;
554
+ }).join("")}</div>`;
555
+ }
556
+
557
+ function toolInput(tool) {
558
+ return tool && tool.input && typeof tool.input === "object" ? tool.input : {};
559
+ }
560
+
561
+ function toolFilePath(tool) {
562
+ const input = toolInput(tool);
563
+ for (const key of ["file_path", "filePath", "path", "target_path", "targetPath"]) {
564
+ if (typeof input[key] === "string" && input[key].trim()) return input[key].trim();
565
+ }
566
+ return "";
567
+ }
568
+
569
+ function toolCommand(tool) {
570
+ const command = toolInput(tool).command;
571
+ return typeof command === "string" ? command.trim() : "";
572
+ }
573
+
574
+ function deletedPathsFromCommand(command) {
575
+ if (!command) return [];
576
+ const deleted = [];
577
+ const matcher = /(?:^|[;&|]\s*)(?:sudo\s+)?(?:rm|unlink)\s+((?:-[\w-]+\s+)*(?:"[^"]+"|'[^']+'|[^\s;&|]+)(?:\s+(?:"[^"]+"|'[^']+'|[^\s;&|]+))*)/g;
578
+ for (const match of command.matchAll(matcher)) {
579
+ const tokens = match[1].match(/"[^"]+"|'[^']+'|[^\s]+/g) || [];
580
+ for (const token of tokens) {
581
+ if (!token.startsWith("-")) deleted.push(token.replace(/^(?:"|')|(?:"|')$/g, ""));
582
+ }
583
+ }
584
+ return [...new Set(deleted)];
585
+ }
586
+
587
+ function activityInfo(tool) {
588
+ const name = String(tool?.name || "");
589
+ const file = toolFilePath(tool);
590
+ const command = toolCommand(tool);
591
+ const deleted = name === "Delete" || name === "Remove" ? (file ? [file] : []) : deletedPathsFromCommand(command);
592
+ if (name === "Read") return { kind: "read", verb: "Read", label: `Read ${basename(file) || "file"}`, file, deleted: [] };
593
+ if (name === "Write") return { kind: "created", verb: "Created", label: `Created ${basename(file) || "file"}`, file, deleted: [] };
594
+ if (name === "Edit" || name === "MultiEdit") return { kind: "changed", verb: "Changed", label: `Changed ${basename(file) || "file"}`, file, deleted: [] };
595
+ if (name === "Delete" || name === "Remove") return { kind: "deleted", verb: "Deleted", label: `Deleted ${basename(file) || "file"}`, file, deleted };
596
+ if (name === "Bash") return { kind: "command", verb: "Ran", label: `Ran ${truncate(command.replace(/\s+/g, " "), 76) || "command"}`, command, deleted };
597
+ if (name === "Grep" || name === "Glob") return { kind: "search", verb: "Searched", label: toolUseLabel(tool), file: "", deleted: [] };
598
+ if (name === "Agent" || name === "Task") return { kind: "agent", verb: "Delegated", label: toolUseLabel(tool), file: "", deleted: [] };
599
+ return { kind: "other", verb: "Used", label: toolUseLabel(tool), file: "", deleted: [] };
600
+ }
601
+
602
+ function activityIcon(kind) {
603
+ const icons = {
604
+ read: '<path d="M3.5 4.5A2.5 2.5 0 0 1 6 2h6.5v12H6a2.5 2.5 0 0 0-2.5 2.5v-12Z"/><path d="M12.5 2H14a2.5 2.5 0 0 1 2.5 2.5v12A2.5 2.5 0 0 0 14 14h-1.5"/>',
605
+ created: '<path d="M4 2.5h6L14 6v9.5H4z"/><path d="M10 2.5V6h4M9 8.5v5M6.5 11h5"/>',
606
+ changed: '<path d="m4 13.5 1.3-3.8L12.8 2.2a1.5 1.5 0 0 1 2.1 2.1l-7.5 7.5L4 13.5Z"/><path d="m11.7 3.3 2.1 2.1"/>',
607
+ deleted: '<path d="M4.5 5.5h9M7 5.5v-2h4v2M6 7.5v6M9 7.5v6M12 7.5v6M5 5.5l.7 10h6.6l.7-10"/>',
608
+ command: '<path d="m4 5 3 3-3 3M9 12h4"/>',
609
+ search: '<circle cx="8" cy="8" r="4.5"/><path d="m11.5 11.5 3 3"/>',
610
+ agent: '<path d="M5 13.5 3.5 15V5.5A2.5 2.5 0 0 1 6 3h6a2.5 2.5 0 0 1 2.5 2.5v5A2.5 2.5 0 0 1 12 13H7l-2 2Z"/><path d="M7 7.5h.01M10 7.5h.01M13 7.5h.01"/>',
611
+ other: '<path d="M8 2.5v11M2.5 8h11"/>',
612
+ };
613
+ return `<svg class="chat-activity-row__icon" viewBox="0 0 18 18" aria-hidden="true">${icons[kind] || icons.other}</svg>`;
614
+ }
615
+
616
+ function activityDetailLines(tool, info) {
617
+ const lines = [];
618
+ if (tool?.source === "subagent") lines.push(`<li><span>Agent</span><code>Subagent</code></li>`);
619
+ if (info.file) lines.push(`<li><span>File</span><code title="${esc(info.file)}">${esc(info.file)}</code></li>`);
620
+ for (const deleted of info.deleted || []) lines.push(`<li><span>Deleted</span><code title="${esc(deleted)}">${esc(deleted)}</code></li>`);
621
+ if (info.command) lines.push(`<li><span>Command</span><code>${esc(info.command)}</code></li>`);
622
+ if (tool?.resultPreview) lines.push(`<li class="chat-activity-row__output"><span>${tool.isError ? "Error" : "Output"}</span><pre>${esc(tool.resultPreview)}</pre></li>`);
623
+ if (!lines.length) lines.push(`<li><span>Activity</span><code>${esc(toolUseLabel(tool))}</code></li>`);
624
+ return lines.join("");
625
+ }
626
+
627
+ function chipHTML(tool) {
628
+ const info = activityInfo(tool);
629
+ const status = tool.status || "started";
630
+ const completed = status === "completed";
631
+ return `<details class="chat-activity-row chat-activity-row--${esc(info.kind)}" data-chip-id="${esc(tool.id || "")}" data-chip-status="${esc(status)}">
632
+ <summary>
633
+ ${activityIcon(info.kind)}
634
+ <span class="chat-activity-row__label">${tool.source === "subagent" ? `Subagent · ${esc(info.label)}` : esc(info.label)}</span>
635
+ <span class="chat-activity-row__status">${completed ? "done" : status === "failed" ? "failed" : "working"}</span>
636
+ <svg class="chat-activity-row__chevron" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="m4 5 3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
637
+ </summary>
638
+ <ul class="chat-activity-row__details">${activityDetailLines(tool, info)}</ul>
639
+ </details>`;
640
+ }
641
+
642
+ function activityCounts(tools) {
643
+ const counts = { read: 0, created: 0, changed: 0, deleted: 0, command: 0 };
644
+ for (const tool of tools) {
645
+ const info = activityInfo(tool);
646
+ if (Object.hasOwn(counts, info.kind)) counts[info.kind] += 1;
647
+ counts.deleted += (info.deleted || []).length;
648
+ }
649
+ return counts;
650
+ }
651
+
652
+ function plural(count, singular) { return `${count} ${singular}${count === 1 ? "" : "s"}`; }
653
+
654
+ function activitySummaryHTML(turn) {
655
+ if (turn.role !== "assistant" && turn.role !== "system") return "";
656
+ const tools = Array.isArray(turn.toolUses) ? turn.toolUses : [];
657
+ if (!turn.durationMs && !tools.length) return "";
658
+ const seconds = Math.max(1, Math.round((turn.durationMs || 0) / 1000));
659
+ const counts = activityCounts(tools);
660
+ const countBits = [
661
+ counts.read && plural(counts.read, "file read"),
662
+ counts.created && plural(counts.created, "file created"),
663
+ counts.changed && plural(counts.changed, "file changed"),
664
+ counts.deleted && plural(counts.deleted, "file deleted"),
665
+ counts.command && plural(counts.command, "command run"),
666
+ ].filter(Boolean);
667
+ const reasoning = typeof turn.reasoning === "string" ? turn.reasoning.trim() : "";
668
+ const reasoningDetail = reasoning
669
+ ? `<div class="chat-reasoning-output"><strong>Thought process</strong><pre>${esc(reasoning)}</pre></div>`
670
+ : `<span class="chat-reasoning-unavailable">No provider-visible thought summary was emitted.</span>`;
671
+ const completion = window.OpenKanChatMotion?.render?.({ phase: "complete", label: "Completed" }) || "";
672
+ return `<details class="chat-activity-summary"><summary><span class="chat-activity-completion" data-chat-completion="${esc(turn.ts || "")}">${completion}</span><span class="chat-activity-summary__title">Thought for ${seconds}s</span>${countBits.length ? `<span class="chat-activity-summary__counts">${esc(countBits.join(" · "))}</span>` : ""}<svg class="chat-activity-summary__chevron" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="m4 5 3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></summary><div class="chat-activity-details">${reasoningDetail}</div></details>`;
673
+ }
674
+
675
+ function chipsHTML(turn) {
676
+ const toolUses = Array.isArray(turn.toolUses) ? turn.toolUses : [];
677
+ if (toolUses.length === 0) return "";
678
+ return `<div class="chat-chips chat-activity-feed" data-chips-for="${esc(turn.ts || "")}">${toolUses.map(chipHTML).join("")}</div>`;
679
+ }
680
+
681
+ /* ----------------------------------------------------------------------
682
+ * Transcript rendering
683
+ * -------------------------------------------------------------------- */
684
+ async function renderTranscript({ completionTs = "" } = {}) {
685
+ const node = state.root?.querySelector("#chat-sidebar-transcript");
686
+ if (!node) return;
687
+ syncHeroState();
688
+ if (state.transcript.length === 0) {
689
+ node.innerHTML = `<div class="chat-empty">No messages yet. Type below and press Enter to send.</div>`;
690
+ hideNewMessagesPill();
691
+ return;
692
+ }
693
+ // Build a flat list of HTML fragments: chips (for assistant turns) then
694
+ // the bubble. Order is chips → bubble for assistant turns; user/system
695
+ // turns render just the bubble.
696
+ const parts = [];
697
+ for (const turn of state.transcript) {
698
+ const role = turn.role || "assistant";
699
+ if (role === "assistant" || role === "system") { parts.push(activitySummaryHTML(turn)); if (role === "assistant") parts.push(chipsHTML(turn)); }
700
+ parts.push(bubbleHTML(turn));
701
+ }
702
+ const wasNearBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 80;
703
+ // Rendering replaces the transcript DOM. Stop any former terminal cue
704
+ // first so an orphaned GSAP timeline cannot survive the replacement.
705
+ node.querySelectorAll("[data-chat-status-motion]").forEach((motion) => window.OpenKanChatMotion?.stop?.(motion));
706
+ node.innerHTML = parts.join("");
707
+ if (wasNearBottom) {
708
+ node.scrollTop = node.scrollHeight;
709
+ } else {
710
+ state.scrolledUp = true;
711
+ }
712
+
713
+ // Hydrate assistant bubbles with markdown rendering (skip chips — they
714
+ // are static).
715
+ const assistantNodes = node.querySelectorAll(".chat-bubble-assistant [data-bubble-body]");
716
+ for (const el of assistantNodes) {
717
+ const turnTs = el.closest(".chat-bubble")?.getAttribute("data-ts");
718
+ const turn = state.transcript.find((t) => (t.ts || "") === turnTs);
719
+ if (!turn) continue;
720
+ // Put plain text on screen before the asynchronous markdown round-trip.
721
+ // This guarantees a completed response is visible even if markdown
722
+ // rendering is slow or unavailable.
723
+ el.textContent = turn.content || "(No text returned)";
724
+ const html = await renderMarkdown(turn.content || "");
725
+ el.innerHTML = html || esc(turn.content || "(No text returned)");
726
+ }
727
+ if (!state.scrolledUp) node.scrollTop = node.scrollHeight;
728
+ // A completed mark plays only for the newly-finished assistant turn.
729
+ // Historical turns render in their settled state instead of re-running.
730
+ if (completionTs && !state.completedMotionTs.has(completionTs)) {
731
+ const completion = [...node.querySelectorAll("[data-chat-completion]")]
732
+ .find((mark) => mark.dataset.chatCompletion === completionTs);
733
+ if (completion) {
734
+ state.completedMotionTs.add(completionTs);
735
+ window.OpenKanChatMotion?.animate?.(completion);
736
+ }
737
+ }
738
+ }
739
+
740
+ /* ----------------------------------------------------------------------
741
+ * Live SSE for new turns + streamed events
742
+ * -------------------------------------------------------------------- */
743
+ function startLive() {
744
+ if (state.sse || typeof window.EventSource !== "function") return;
745
+ try {
746
+ // Subscribe to the GLOBAL event stream for chat.turn rollups (every
747
+ // session) so the sidebar can react when a different session finishes
748
+ // a turn. Per-session streams (text-delta / tool-use / tool-result /
749
+ // message-done) are wired in startSessionStream below.
750
+ const src = new EventSource("/api/chat/events");
751
+ src.addEventListener("chat.turn", (e) => {
752
+ try {
753
+ const data = JSON.parse(e.data);
754
+ if (data?.sessionId && data.sessionId === state.currentSessionId) {
755
+ const { userTurn, assistantTurn } = data;
756
+ // Drop the optimistic placeholder turn (matched by messageId
757
+ // when present, otherwise by ts+role+content).
758
+ state.transcript = state.transcript.filter((t) => {
759
+ if (userTurn?.messageId && t.messageId === userTurn.messageId) return false;
760
+ return !(t.ts === userTurn?.ts && t.role === "user" && t.content === userTurn.content);
761
+ });
762
+ const userAdded = appendTurnIfNew(userTurn);
763
+ const assistantAdded = appendTurnIfNew(assistantTurn);
764
+ state.inFlight = false;
765
+ removeStreamingIndicator();
766
+ updateAbortButton();
767
+ // Both the project and session SSE streams may report this rollup.
768
+ // Re-render only when it changed the canonical transcript.
769
+ if (userAdded || assistantAdded) void renderTranscript({ completionTs: assistantAdded ? (assistantTurn?.ts || "") : "" });
770
+ removeStreamingIndicator();
771
+ stopSessionStream();
772
+ composerFeedback("Response complete.");
773
+ }
774
+ } catch (_err) { /* ignore */ }
775
+ });
776
+ src.addEventListener("chat.session-archived", (e) => {
777
+ try {
778
+ const data = JSON.parse(e.data);
779
+ if (data?.sessionId === state.currentSessionId) state.currentSessionId = "";
780
+ } catch (_err) { /* ignore */ }
781
+ });
782
+ src.onerror = () => { /* let EventSource auto-reconnect */ };
783
+ state.sse = src;
784
+ } catch (_err) {
785
+ state.sse = null;
786
+ }
787
+ }
788
+
789
+ function startSessionStream() {
790
+ stopSessionStream();
791
+ if (!state.currentSessionId || typeof window.EventSource !== "function") return;
792
+ try {
793
+ const sid = state.currentSessionId;
794
+ const src = new EventSource(`/api/chat/sessions/${encodeURIComponent(sid)}/events`);
795
+ const transcript = state.root?.querySelector("#chat-sidebar-transcript");
796
+ const appendToken = (text) => {
797
+ if (!text) return;
798
+ let bubble = transcript?.querySelector(".chat-bubble-row-assistant:last-child .chat-bubble-body");
799
+ if (!bubble && transcript) {
800
+ const row = document.createElement("div");
801
+ row.className = "chat-bubble-row chat-bubble-row-assistant";
802
+ row.innerHTML = `<div class="chat-bubble chat-bubble-assistant" data-ts="live" data-status="streaming"><div class="chat-bubble-meta"><span>Claude Code · responding</span></div><div class="chat-bubble-body chat-bubble-body-stream" data-bubble-body></div></div>`;
803
+ transcript.appendChild(row);
804
+ bubble = row.querySelector("[data-bubble-body]");
805
+ }
806
+ if (!bubble) return;
807
+ // Stream into the last text block; schedule a debounced incremental
808
+ // markdown re-render so formatting appears progressively rather than
809
+ // only at message-done time. The token-level `append` keeps the
810
+ // streaming text on screen between debounced renders, so there is
811
+ // no flash of unformatted content.
812
+ if (bubble.dataset.streaming === "1") {
813
+ bubble.append(text);
814
+ } else {
815
+ bubble.textContent = text;
816
+ bubble.dataset.streaming = "1";
817
+ }
818
+ scheduleStreamRender(bubble);
819
+ ensureStreamingIndicator();
820
+ // Auto-scroll if user is near the bottom.
821
+ maybeAutoScroll(transcript);
822
+ };
823
+ src.addEventListener("chat.text-delta", (e) => {
824
+ try {
825
+ const data = JSON.parse(e.data);
826
+ appendToken(data?.text || "");
827
+ } catch (_err) { /* ignore */ }
828
+ });
829
+ src.addEventListener("chat.status", (e) => {
830
+ try { updateLiveStatus(JSON.parse(e.data)); } catch (_err) { /* ignore */ }
831
+ });
832
+ src.addEventListener("chat.activity", (e) => {
833
+ try {
834
+ const event = JSON.parse(e.data);
835
+ state.liveActivity = [...(state.liveActivity || []), event].slice(-80);
836
+ renderLiveActivity();
837
+ } catch (_err) { /* ignore */ }
838
+ });
839
+ src.addEventListener("chat.tool-use", (e) => {
840
+ try {
841
+ const data = JSON.parse(e.data);
842
+ if (!data || !data.id) return;
843
+ state.liveChips = state.liveChips || [];
844
+ // Skip if we already have this chip id (defensive — the server
845
+ // can fan the same event twice across channels).
846
+ if (state.liveChips.some((c) => c.id === data.id)) return;
847
+ const label = toolUseLabel({ name: data.name, input: data.input || {} });
848
+ updateLiveStatus({
849
+ phase: data.name === "WebSearch" ? "searching" : "tool",
850
+ label,
851
+ });
852
+ state.liveChips.push({
853
+ id: data.id,
854
+ name: data.name,
855
+ input: data.input || {},
856
+ status: data.status || "started",
857
+ });
858
+ renderLiveChips();
859
+ } catch (_err) { /* ignore */ }
860
+ });
861
+ src.addEventListener("chat.tool-input-delta", (_e) => {
862
+ // Streaming input is purely visual; we keep the chip in "started"
863
+ // state and let tool-result transition it to completed/failed.
864
+ });
865
+ src.addEventListener("chat.tool-result", (e) => {
866
+ try {
867
+ const data = JSON.parse(e.data);
868
+ if (!data || !data.id) return;
869
+ const chip = (state.liveChips || []).find((c) => c.id === data.id);
870
+ if (chip) {
871
+ chip.status = data.isError ? "failed" : "completed";
872
+ chip.resultPreview = typeof data.content === "string"
873
+ ? data.content.slice(0, 200)
874
+ : (Array.isArray(data.content) ? data.content.map((c) => c.text || "").join("").slice(0, 200) : "");
875
+ chip.isError = !!data.isError;
876
+ }
877
+ renderLiveChips();
878
+ // A tool result does not necessarily produce text immediately. Do
879
+ // not leave "Searching the web" (or another finished operation)
880
+ // rendered until Claude emits its next response token.
881
+ syncLiveToolStatus();
882
+ } catch (_err) { /* ignore */ }
883
+ });
884
+ src.addEventListener("chat.message-done", (_e) => {
885
+ // Finalise streaming bubble — re-render markdown now that content
886
+ // is complete, and reset live state.
887
+ finalizeLiveBubble();
888
+ removeStreamingIndicator();
889
+ });
890
+ state.sessionSse = src;
891
+ } catch (_err) {
892
+ state.sessionSse = null;
893
+ }
894
+ }
895
+
896
+ function stopSessionStream() {
897
+ if (state.sessionSse) {
898
+ try { state.sessionSse.close(); } catch (_err) { /* ignore */ }
899
+ state.sessionSse = null;
900
+ }
901
+ cancelStreamRender();
902
+ state.liveChips = null;
903
+ state.liveActivity = [];
904
+ state.liveBubble = null;
905
+ // A session can close after its final tool result but before a text
906
+ // delta or message-done event reaches this EventSource. Terminal cleanup
907
+ // must never retain the last tool label.
908
+ removeStreamingIndicator();
909
+ }
910
+
911
+ function renderLiveChips() {
912
+ if (!state.root) return;
913
+ const transcript = state.root.querySelector("#chat-sidebar-transcript");
914
+ if (!transcript) return;
915
+ // Insert a chip-stack container before the last assistant bubble (if
916
+ // any). If no assistant bubble yet, we attach one to a freshly created
917
+ // empty bubble row at the bottom so the chips have a sibling.
918
+ let row = transcript.querySelector(".chat-bubble-row-assistant:last-child");
919
+ if (!row) return;
920
+ let chipsNode = row.parentElement?.querySelector(":scope > .chat-chips-live");
921
+ const list = state.liveChips || [];
922
+ if (list.length === 0) {
923
+ if (chipsNode) chipsNode.remove();
924
+ return;
925
+ }
926
+ if (!chipsNode) {
927
+ chipsNode = document.createElement("div");
928
+ chipsNode.className = "chat-chips chat-chips-live";
929
+ row.parentElement?.insertBefore(chipsNode, row);
930
+ }
931
+ // While work is live, show only the current operation. The full audit is
932
+ // rendered from the persisted assistant turn once the prompt settles.
933
+ const current = [...list].reverse().find((tool) => tool.status === "started" || tool.status === "streaming");
934
+ if (!current) { chipsNode.remove(); return; }
935
+ chipsNode.innerHTML = chipHTML(current);
936
+ maybeAutoScroll(transcript);
937
+ }
938
+
939
+ function currentLiveTool() {
940
+ return [...(state.liveChips || [])].reverse().find((tool) => (
941
+ tool.status === "started" || tool.status === "streaming"
942
+ ));
943
+ }
944
+
945
+ function syncLiveToolStatus() {
946
+ const activeTool = currentLiveTool();
947
+ if (activeTool) {
948
+ updateLiveStatus({
949
+ phase: activeTool.name === "WebSearch" ? "searching" : "tool",
950
+ label: toolUseLabel(activeTool),
951
+ });
952
+ return;
953
+ }
954
+ // Claude continues reasoning after a tool completes. This intentionally
955
+ // replaces the completed operation rather than exposing noisy internals.
956
+ updateLiveStatus({ phase: "thinking", label: "Thinking" });
957
+ }
958
+
959
+ function activityRaw(event) { return event?.raw && typeof event.raw === "object" ? event.raw : {}; }
960
+ function activityTool(event) {
961
+ const raw = activityRaw(event);
962
+ const block = raw.content_block && typeof raw.content_block === "object" ? raw.content_block : {};
963
+ const messageContent = Array.isArray(raw.message?.content) ? raw.message.content : [];
964
+ const messageTool = messageContent.find((part) => part && typeof part === "object" && part.type === "tool_use") || {};
965
+ return {
966
+ id: block.id || messageTool.id || raw.tool_use_id || raw.toolUseId || "",
967
+ name: block.name || messageTool.name || raw.tool_name || raw.mcp_tool_name || raw.name || "",
968
+ input: block.input || messageTool.input || raw.tool_input || raw.toolInput || {},
969
+ type: block.type || messageTool.type || "",
970
+ };
971
+ }
972
+ function activityParentId(event) {
973
+ const raw = activityRaw(event);
974
+ return event?.parentToolUseId || raw.parent_tool_use_id || raw.parentToolUseId || raw.message?.parent_tool_use_id || raw.message?.parentToolUseId || "";
975
+ }
976
+ function activityPreview(event) {
977
+ const raw = activityRaw(event);
978
+ const delta = raw.delta && typeof raw.delta === "object" ? raw.delta : {};
979
+ const block = raw.content_block && typeof raw.content_block === "object" ? raw.content_block : {};
980
+ const content = Array.isArray(raw.message?.content) ? raw.message.content : [];
981
+ const text = [
982
+ delta.thinking, delta.text, block.thinking, block.text, block.content,
983
+ ...content.filter((part) => part && typeof part === "object").map((part) => part.thinking || part.text),
984
+ raw.last_assistant_message, raw.summary,
985
+ ].find((value) => typeof value === "string" && value.trim());
986
+ return typeof text === "string" ? truncate(text.replace(/\s+/g, " ").trim(), 180) : "";
987
+ }
988
+ function isForwardedTranscript(event) {
989
+ const raw = activityRaw(event);
990
+ // Full assistant/user snapshots carry the useful child transcript. Do
991
+ // not render token-level deltas as rows; they would create a noisy wall
992
+ // of blocks while streaming.
993
+ return Boolean(activityParentId(event)) && ["assistant", "user"].includes(String(raw.type || "").toLowerCase());
994
+ }
995
+ function activityLabel(event) {
996
+ const raw = activityRaw(event);
997
+ const tool = activityTool(event);
998
+ const hook = raw.hook_event_name || raw.hookEventName;
999
+ if (hook === "SubagentStart") return `Started ${raw.agent_type || raw.agentType || "subagent"}`;
1000
+ if (hook === "SubagentStop") return `Completed ${raw.agent_type || raw.agentType || "subagent"}`;
1001
+ if (tool.name === "Agent" || tool.name === "Task") return `Delegating to ${tool.input?.subagent_type || tool.input?.subagentType || "subagent"}`;
1002
+ if (tool.name) return toolUseLabel({ name: tool.name, input: tool.input || {} });
1003
+ if (isForwardedTranscript(event)) return activityPreview(event) ? "Subagent update" : "Subagent working";
1004
+ if (raw.subtype === "api_retry") return `Retrying API request (${raw.attempt || 1}/${raw.max_retries || "?"})`;
1005
+ return [event?.type, event?.subtype].filter(Boolean).join(" · ") || "Agent activity";
1006
+ }
1007
+ function isImportantActivity(event) {
1008
+ const raw = activityRaw(event);
1009
+ const tool = activityTool(event);
1010
+ const type = String(event?.type || raw.type || "").toLowerCase();
1011
+ const subtype = String(event?.subtype || raw.subtype || "").toLowerCase();
1012
+ // Forwarded assistant snapshots are often emitted per token. They are
1013
+ // intentionally excluded from live UI; completed turns retain the real
1014
+ // tool/file audit instead of transient thought fragments.
1015
+ if (isForwardedTranscript(event)) return false;
1016
+ // Hook records and native tool boundaries convey meaningful lifecycle
1017
+ // changes; intermediate text/thinking deltas remain excluded.
1018
+ if (tool.type === "tool_use" || tool.type === "tool_result" || tool.name) return true;
1019
+ if (raw.hook_event_name || raw.hookEventName || raw.mcp_server_name || raw.mcp_tool_name) return true;
1020
+ if (subtype.includes("retry") || subtype.includes("hook") || subtype.includes("team") || subtype.includes("workflow") || subtype.includes("agent") || subtype.includes("mcp")) return true;
1021
+ return type === "system" || type === "error";
1022
+ }
1023
+ function nativeActivityGroups(events) {
1024
+ const groups = new Map();
1025
+ const roots = new Map();
1026
+ for (const event of events) {
1027
+ const tool = activityTool(event);
1028
+ if ((tool.name === "Agent" || tool.name === "Task") && tool.id) {
1029
+ roots.set(tool.id, `subagent:${tool.id}`);
1030
+ groups.set(`subagent:${tool.id}`, {
1031
+ id: `subagent:${tool.id}`,
1032
+ title: String(tool.input?.subagent_type || tool.input?.subagentType || "Subagent"),
1033
+ state: "running",
1034
+ parentId: activityParentId(event) && roots.get(activityParentId(event)) || null,
1035
+ events: [],
1036
+ });
1037
+ }
1038
+ }
1039
+ for (const event of events) {
1040
+ const raw = activityRaw(event);
1041
+ const tool = activityTool(event);
1042
+ const parent = activityParentId(event);
1043
+ const hook = raw.hook_event_name || raw.hookEventName;
1044
+ const key = (tool.name === "Agent" || tool.name === "Task") && tool.id && roots.get(tool.id)
1045
+ ? roots.get(tool.id)
1046
+ : parent
1047
+ ? (roots.get(parent) || `subagent:${parent}`)
1048
+ : hook === "SubagentStart" || hook === "SubagentStop"
1049
+ ? `hook:${raw.agent_id || raw.agentId || raw.agent_type || raw.agentType || "subagent"}`
1050
+ : "coordinator";
1051
+ if (!groups.has(key)) {
1052
+ groups.set(key, {
1053
+ id: key,
1054
+ title: key === "coordinator" ? "Coordinator" : String(raw.agent_type || raw.agentType || "Subagent"),
1055
+ state: hook === "SubagentStop" ? "completed" : "running",
1056
+ parentId: null,
1057
+ events: [],
1058
+ });
1059
+ }
1060
+ const group = groups.get(key);
1061
+ if (hook === "SubagentStop") group.state = "completed";
1062
+ if (hook === "SubagentStart") group.state = "running";
1063
+ if (tool.name === "Agent" || tool.name === "Task") group.state = "running";
1064
+ group.events.push(event);
1065
+ }
1066
+ return [...groups.values()].filter((group) => group.events.length > 0);
1067
+ }
1068
+ function nativeActivityTreeHTML(groups) {
1069
+ const byParent = new Map();
1070
+ for (const group of groups) {
1071
+ const parent = group.parentId && groups.some((candidate) => candidate.id === group.parentId) ? group.parentId : "root";
1072
+ byParent.set(parent, [...(byParent.get(parent) || []), group]);
1073
+ }
1074
+ const renderGroup = (group, depth) => `<details class="chat-native-agent" data-native-depth="${depth}" open><summary><span class="chat-native-agent-state chat-native-agent-state--${esc(group.state)}"></span><strong>${esc(group.title)}</strong><span>${group.state === "completed" ? "completed" : "working"} · ${group.events.length}</span></summary><div>${group.events.slice(-3).map(nativeActivityEventHTML).join("")}${(byParent.get(group.id) || []).map((child) => renderGroup(child, depth + 1)).join("")}</div></details>`;
1075
+ return (byParent.get("root") || []).map((group) => renderGroup(group, 0)).join("");
1076
+ }
1077
+ function nativeActivityEventHTML(event) {
1078
+ const tool = activityTool(event);
1079
+ if (tool.name) return chipHTML({ ...tool, status: "completed" });
1080
+ const preview = activityPreview(event);
1081
+ const raw = activityRaw(event);
1082
+ const hook = raw.hook_event_name || raw.hookEventName;
1083
+ const label = activityLabel(event);
1084
+ return `<details class="chat-activity-row chat-activity-row--agent chat-native-event"><summary>${activityIcon("agent")}<span class="chat-activity-row__label">${esc(label)}</span><span class="chat-activity-row__status">${hook === "SubagentStop" ? "done" : "working"}</span><svg class="chat-activity-row__chevron" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="m4 5 3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></summary>${preview ? `<div class="chat-native-event__detail">${esc(preview)}</div>` : ""}</details>`;
1085
+ }
1086
+ function renderLiveActivity() {
1087
+ const transcript = state.root?.querySelector("#chat-sidebar-transcript");
1088
+ if (!transcript) return;
1089
+ let node = transcript.querySelector(":scope > .chat-live-activity");
1090
+ if (!node) { node = document.createElement("details"); node.className = "chat-live-activity"; node.open = true; transcript.appendChild(node); }
1091
+ const events = (state.liveActivity || []).filter(isImportantActivity);
1092
+ if (events.length === 0) { node?.remove(); return; }
1093
+ const latest = events.at(-1);
1094
+ const groups = nativeActivityGroups(events);
1095
+ const subagentCount = groups.filter((group) => group.id !== "coordinator").length;
1096
+ const motion = window.OpenKanChatMotion?.render?.({
1097
+ phase: latest?.type,
1098
+ label: activityLabel(latest),
1099
+ name: activityTool(latest).name,
1100
+ }) || "";
1101
+ node.innerHTML = `<summary><span class="chat-live-activity-motion">${motion}</span><span>Native activity${subagentCount ? ` · ${subagentCount} subagent${subagentCount === 1 ? "" : "s"}` : ""}</span><span>${events.length}</span></summary><div class="chat-live-activity-list chat-native-activity-tree">${nativeActivityTreeHTML(groups)}</div>`;
1102
+ window.OpenKanChatMotion?.animateWithin?.(node);
1103
+ maybeAutoScroll(transcript);
1104
+ }
1105
+
1106
+ function finalizeLiveBubble() {
1107
+ if (!state.root) return;
1108
+ const transcript = state.root.querySelector("#chat-sidebar-transcript");
1109
+ const bubble = transcript?.querySelector(".chat-bubble-row-assistant:last-child .chat-bubble-body");
1110
+ if (bubble && bubble.dataset.streaming === "1") {
1111
+ const text = bubble.textContent || "";
1112
+ // Cancel any pending incremental render so it cannot race the final
1113
+ // pass and overwrite the cache with stale content.
1114
+ cancelStreamRender();
1115
+ // Mark as no longer streaming BEFORE the await so any late response
1116
+ // from a scheduled flush is dropped (token check + dataset check).
1117
+ bubble.removeAttribute("data-streaming");
1118
+ // Render markdown now that the stream is final.
1119
+ if (text) {
1120
+ renderMarkdown(text).then((html) => {
1121
+ if (bubble.isConnected) bubble.innerHTML = html || esc(text);
1122
+ });
1123
+ }
1124
+ }
1125
+ removeStreamingIndicator();
1126
+ transcript?.querySelector(":scope > .chat-live-activity")?.remove();
1127
+ }
1128
+
1129
+ /**
1130
+ * Streaming "Working" indicator — a single muted italic line appended
1131
+ * to the transcript while the assistant turn is in flight. Hidden when
1132
+ * the turn completes (via finalizeLiveBubble) or when the transcript is
1133
+ * re-rendered.
1134
+ */
1135
+ function updateLiveStatus(status = {}) {
1136
+ const transcript = state.root?.querySelector("#chat-sidebar-transcript");
1137
+ if (!transcript) return;
1138
+ let node = transcript.querySelector(":scope > .chat-bubble-streaming-indicator");
1139
+ if (!node) {
1140
+ node = document.createElement("div");
1141
+ node.className = "chat-bubble-streaming-indicator";
1142
+ node.setAttribute("aria-live", "polite");
1143
+ transcript.appendChild(node);
1144
+ }
1145
+ const label = status.label || (status.phase === "tool" ? "Using a tool" : "Thinking");
1146
+ const motion = window.OpenKanChatMotion?.render?.(status) || "";
1147
+ if (node.dataset.statusLabel === label) return;
1148
+ node.querySelectorAll("[data-chat-status-motion]").forEach(mark => window.OpenKanChatMotion?.stop?.(mark));
1149
+ node.dataset.statusLabel = label;
1150
+ node.innerHTML = `${motion}<span>${esc(label)}</span>`;
1151
+ window.OpenKanChatMotion?.animateWithin?.(node);
1152
+ maybeAutoScroll(transcript);
1153
+ }
1154
+ function ensureStreamingIndicator() { updateLiveStatus({ phase: "thinking", label: "Writing response" }); }
1155
+ function removeStreamingIndicator() {
1156
+ const transcript = state.root?.querySelector("#chat-sidebar-transcript");
1157
+ const node = transcript?.querySelector(":scope > .chat-bubble-streaming-indicator");
1158
+ if (node) {
1159
+ node.querySelectorAll?.("[data-chat-status-motion]").forEach((motion) => window.OpenKanChatMotion?.stop?.(motion));
1160
+ node.remove();
1161
+ }
1162
+ }
1163
+
1164
+ function maybeAutoScroll(node) {
1165
+ if (!node) return;
1166
+ if (state.scrolledUp) {
1167
+ showNewMessagesPill();
1168
+ return;
1169
+ }
1170
+ node.scrollTop = node.scrollHeight;
1171
+ }
1172
+
1173
+ function showNewMessagesPill() {
1174
+ const pill = state.root?.querySelector("#chat-sidebar-new-messages");
1175
+ if (pill) pill.hidden = false;
1176
+ }
1177
+ function hideNewMessagesPill() {
1178
+ const pill = state.root?.querySelector("#chat-sidebar-new-messages");
1179
+ if (pill) pill.hidden = true;
1180
+ state.scrolledUp = false;
1181
+ }
1182
+
1183
+ /** Append a turn only if we have not already received it. */
1184
+ function appendTurnIfNew(turn) {
1185
+ if (!turn) return;
1186
+ const dup = state.transcript.find((t) => {
1187
+ if (turn.messageId && t.messageId) return t.messageId === turn.messageId;
1188
+ return t.ts === turn.ts && t.role === turn.role
1189
+ && (t.content || "") === (turn.content || "");
1190
+ });
1191
+ if (dup) return false;
1192
+ state.transcript.push(turn);
1193
+ syncHeroState();
1194
+ return true;
1195
+ }
1196
+ function stopLive() {
1197
+ if (state.sse) {
1198
+ try { state.sse.close(); } catch (_err) { /* ignore */ }
1199
+ state.sse = null;
1200
+ }
1201
+ stopSessionStream();
1202
+ }
1203
+
1204
+ /* ----------------------------------------------------------------------
1205
+ * Chip expand/collapse
1206
+ * -------------------------------------------------------------------- */
1207
+ function bindChipChips() {
1208
+ if (!state.root) return;
1209
+ const transcript = state.root.querySelector("#chat-sidebar-transcript");
1210
+ if (!transcript) return;
1211
+ bindChipClicks(transcript);
1212
+ }
1213
+ function bindChipClicks(scope) {
1214
+ if (!scope) return;
1215
+ const chips = scope.querySelectorAll(".chat-chip");
1216
+ for (const chip of chips) {
1217
+ if (chip.dataset.bound === "1") continue;
1218
+ chip.dataset.bound = "1";
1219
+ const toggle = (e) => {
1220
+ if (e) { e.preventDefault(); e.stopPropagation(); }
1221
+ const details = chip.querySelector(".chat-chip-details");
1222
+ const expanded = chip.getAttribute("aria-expanded") === "true";
1223
+ if (expanded) {
1224
+ if (details) details.hidden = true;
1225
+ chip.setAttribute("aria-expanded", "false");
1226
+ } else {
1227
+ if (details) details.hidden = false;
1228
+ chip.setAttribute("aria-expanded", "true");
1229
+ }
1230
+ };
1231
+ chip.addEventListener("click", toggle);
1232
+ chip.addEventListener("keydown", (e) => {
1233
+ if (e.key === "Enter" || e.key === " ") toggle(e);
1234
+ else if (e.key === "Escape") {
1235
+ const details = chip.querySelector(".chat-chip-details");
1236
+ if (details) details.hidden = true;
1237
+ chip.setAttribute("aria-expanded", "false");
1238
+ }
1239
+ });
1240
+ }
1241
+ }
1242
+
1243
+ /* ----------------------------------------------------------------------
1244
+ * Task references from board drag-and-drop
1245
+ * -------------------------------------------------------------------- */
1246
+ function normaliseDroppedTask(value) {
1247
+ const candidate = value?.task || value;
1248
+ if (!candidate || typeof candidate.id !== "string" || !candidate.id.trim()) return null;
1249
+ return {
1250
+ id: candidate.id.trim(),
1251
+ title: typeof candidate.title === "string" ? candidate.title.trim() : "Untitled task",
1252
+ column: typeof candidate.column === "string" ? candidate.column : "",
1253
+ };
1254
+ }
1255
+
1256
+ function readDraggedTask(event) {
1257
+ const transfer = event?.dataTransfer;
1258
+ const types = Array.from(transfer?.types || []);
1259
+ for (const type of ["application/x-openkan-task", "text/x-openkan-task", "application/json"]) {
1260
+ if (types.length && !types.includes(type)) continue;
1261
+ try {
1262
+ const parsed = JSON.parse(transfer?.getData(type) || "");
1263
+ const task = normaliseDroppedTask(parsed);
1264
+ if (task) return task;
1265
+ } catch (_err) { /* try the next portable representation */ }
1266
+ }
1267
+ return normaliseDroppedTask(window.OpenKanActiveTaskDrag);
1268
+ }
1269
+
1270
+ function taskMentionToken(taskId) { return `@task(${taskId})`; }
1271
+
1272
+ function renderTaskMentionTray() {
1273
+ const tray = state.root?.querySelector("#chat-sidebar-mention-tray");
1274
+ const input = state.root?.querySelector("#chat-sidebar-input");
1275
+ if (!tray || !input) return;
1276
+ const active = [...state.taskMentions.values()];
1277
+ tray.hidden = active.length === 0;
1278
+ tray.replaceChildren();
1279
+ for (const task of active) {
1280
+ const chip = document.createElement("button");
1281
+ chip.type = "button";
1282
+ chip.className = "chat-sidebar__mention-chip";
1283
+ chip.dataset.chatRemoveMention = task.id;
1284
+ chip.title = `Remove task reference: ${task.title}`;
1285
+ chip.setAttribute("aria-label", `Remove task reference: ${task.title}`);
1286
+ const prefix = document.createElement("span");
1287
+ prefix.className = "chat-sidebar__mention-chip-prefix";
1288
+ prefix.textContent = "Task";
1289
+ const title = document.createElement("span");
1290
+ title.className = "chat-sidebar__mention-chip-title";
1291
+ title.textContent = `#${task.id.replace(/^tsk-/, "").slice(0, 6)}`;
1292
+ const remove = document.createElement("span");
1293
+ remove.className = "chat-sidebar__mention-chip-remove";
1294
+ remove.setAttribute("aria-hidden", "true");
1295
+ remove.textContent = "×";
1296
+ chip.append(prefix, title, remove);
1297
+ tray.append(chip);
1298
+ }
1299
+ }
1300
+
1301
+ function insertTaskMention(task) {
1302
+ const input = state.root?.querySelector("#chat-sidebar-input");
1303
+ if (!input || !task?.id) return;
1304
+ // References belong to the compact tray. Keeping the composer text
1305
+ // untouched means a drop never injects a long, surprising prompt line.
1306
+ state.taskMentions.set(task.id, task);
1307
+ renderTaskMentionTray();
1308
+ saveDraft(); updateAbortButton();
1309
+ input.focus();
1310
+ state.root?.classList.add("chat-sidebar--task-dropped");
1311
+ setTimeout(() => state.root?.classList.remove("chat-sidebar--task-dropped"), 520);
1312
+ }
1313
+
1314
+ function removeTaskMention(taskId) {
1315
+ const input = state.root?.querySelector("#chat-sidebar-input");
1316
+ if (!input || !taskId) return;
1317
+ state.taskMentions.delete(taskId);
1318
+ renderTaskMentionTray();
1319
+ saveDraft(); updateAbortButton();
1320
+ input.focus();
1321
+ }
1322
+
1323
+ /* ----------------------------------------------------------------------
1324
+ * Event handlers
1325
+ * -------------------------------------------------------------------- */
1326
+ function bindEvents() {
1327
+ if (!state.root) return;
1328
+ state.root.addEventListener("click", onClick);
1329
+ state.root.addEventListener("change", onChange);
1330
+ state.root.addEventListener("keydown", onKeyDown);
1331
+ state.root.addEventListener("input", onInput);
1332
+
1333
+ const newMsg = state.root.querySelector("#chat-sidebar-new-messages");
1334
+ if (newMsg) newMsg.addEventListener("click", () => {
1335
+ const transcript = state.root?.querySelector("#chat-sidebar-transcript");
1336
+ if (transcript) transcript.scrollTop = transcript.scrollHeight;
1337
+ hideNewMessagesPill();
1338
+ });
1339
+
1340
+ const transcript = state.root.querySelector("#chat-sidebar-transcript");
1341
+ if (transcript) transcript.addEventListener("scroll", () => {
1342
+ const distance = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight;
1343
+ state.scrolledUp = distance > 80;
1344
+ if (!state.scrolledUp) hideNewMessagesPill();
1345
+ });
1346
+
1347
+ // A board card is copied into chat as a reference. We deliberately
1348
+ // support custom, JSON, and in-page payloads: browser engines vary in
1349
+ // which drag MIME types are readable before the final drop event.
1350
+ state.root.addEventListener("dragenter", (e) => {
1351
+ if (readDraggedTask(e)) state.root.classList.add("chat-sidebar--task-drop");
1352
+ });
1353
+ state.root.addEventListener("dragleave", (e) => {
1354
+ if (!state.root.contains(e.relatedTarget)) state.root.classList.remove("chat-sidebar--task-drop");
1355
+ });
1356
+ state.root.addEventListener("dragover", (e) => {
1357
+ if (e.dataTransfer?.types?.includes("Files")) e.preventDefault();
1358
+ if (readDraggedTask(e)) {
1359
+ e.preventDefault();
1360
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
1361
+ state.root.classList.add("chat-sidebar--task-drop");
1362
+ }
1363
+ });
1364
+ state.root.addEventListener("drop", (e) => {
1365
+ const task = readDraggedTask(e);
1366
+ if (!task) return;
1367
+ e.preventDefault();
1368
+ e.stopPropagation();
1369
+ state.root.classList.remove("chat-sidebar--task-drop");
1370
+ insertTaskMention(task);
1371
+ });
1372
+ state.root.addEventListener("drop", (e) => {
1373
+ if (!e.dataTransfer?.files?.length) return;
1374
+ e.preventDefault();
1375
+ composerFeedback("File attachments are not supported here. Paste text into your message, or reference a task.");
1376
+ });
1377
+
1378
+ }
1379
+
1380
+ /** Global click-away: close any open popover when the user clicks
1381
+ * outside the sidebar's interactive elements. Registered on mount. */
1382
+ function bindGlobalDismiss() {
1383
+ state.dismissController?.abort();
1384
+ state.dismissController = new AbortController();
1385
+ const options = { signal: state.dismissController.signal };
1386
+ document.addEventListener("pointerdown", (e) => {
1387
+ if (!state.root || !state.popoverId) return;
1388
+ const target = e.target;
1389
+ if (!(target instanceof Element)) return;
1390
+ const popover = state.root.querySelector(`#${CSS.escape(state.popoverId)}`);
1391
+ const trigger = state.root.querySelector("[data-popover-open='1']");
1392
+ if (popover?.contains(target) || trigger?.contains(target)) return;
1393
+ closePopover();
1394
+ }, options);
1395
+ document.addEventListener("keydown", (e) => {
1396
+ if (!state.popoverId) return;
1397
+ if (e.key === "Escape") {
1398
+ e.preventDefault();
1399
+ const trigger = state.root?.querySelector("[data-popover-open=\'1\']");
1400
+ closePopover(); trigger?.focus();
1401
+ }
1402
+ }, options);
1403
+ }
1404
+
1405
+ function onClick(e) {
1406
+ if (!state.root) return;
1407
+ const t = e.target;
1408
+ if (!(t instanceof Element)) return;
1409
+ const handle = t.closest("[data-chat-toggle]");
1410
+ if (handle) {
1411
+ toggle();
1412
+ return;
1413
+ }
1414
+ // Bubble copy / retry buttons: delegate before action buttons.
1415
+ const copy = t.closest("[data-chat-copy]");
1416
+ if (copy) { copyToClipboard(copy.getAttribute("data-chat-copy")); return; }
1417
+ const retry = t.closest("[data-chat-retry]");
1418
+ if (retry) { void retryLastTurn(); return; }
1419
+ const mention = t.closest("[data-chat-remove-mention]");
1420
+ if (mention) { removeTaskMention(mention.getAttribute("data-chat-remove-mention")); return; }
1421
+ const prompt = t.closest("[data-chat-prompt]");
1422
+ if (prompt) {
1423
+ const input = state.root.querySelector("#chat-sidebar-input");
1424
+ if (input) {
1425
+ input.value = prompt.getAttribute("data-chat-prompt") || "";
1426
+ input.focus();
1427
+ autoResize(); saveDraft(); updateAbortButton();
1428
+ }
1429
+ return;
1430
+ }
1431
+ const tabBtn = t.closest("[data-tab]");
1432
+ if (tabBtn) {
1433
+ const name = tabBtn.getAttribute("data-tab");
1434
+ if (name) { openTab(name); return; }
1435
+ }
1436
+ const action = t.closest("[data-chat-action]")?.getAttribute("data-chat-action");
1437
+ if (action === "send") void onSend();
1438
+ else if (action === "abort") void onAbort();
1439
+ else if (action === "new") { closePopover(); void onNewSession(); }
1440
+ else if (action === "archive") { closePopover(); void onArchive(); }
1441
+ else if (action === "toggle-activity") { closePopover(); toggleActivity(); setActiveTab(state.activityOpen ? "activity" : null); }
1442
+ else if (action === "open-agent-picker") { openAgentPicker(); return; }
1443
+ else if (action === "open-model-picker") { void openModelPicker(); return; }
1444
+ else if (action === "open-effort-picker") { void openEffortPicker(); return; }
1445
+ else if (action === "reference-task") { void openTaskReferencePicker(); return; }
1446
+ else if (action === "open-attach-menu") { openAttachMenu(); return; }
1447
+ else if (action === "open-session-menu") { openSessionMenu(); return; }
1448
+ else if (action === "import-file") { void onImportFileClick(); return; }
1449
+ else if (action === "add-to-planning") { void onAddToPlanningClick(); return; }
1450
+ else if (action === "pick-session") {
1451
+ const id = t.closest("[data-session-id]")?.getAttribute("data-session-id");
1452
+ if (id) { onPickSessionClick(id); return; }
1453
+ }
1454
+ else if (action === "close-attach") { closePopover(); return; }
1455
+ else if (action === "open-project-picker") { onOpenProjectPicker(); return; }
1456
+ else if (action === "open-docs") { onOpenDocs(); return; }
1457
+ else if (action === "toggle-docs-pane") { onToggleDocsPane(); return; }
1458
+ else if (action === "m1-import") { onM1Import(); return; }
1459
+ else if (action === "planning-cli") { onPlanningCli(); return; }
1460
+ else if (action === "agents-catalog") { onAgentsCatalog(); return; }
1461
+ else if (action === "list-sessions") { onListSessions(); return; }
1462
+ else if (action === "mic") {
1463
+ // Voice input is a placeholder; surfacing as a toast is the lightest
1464
+ // way to confirm the click landed without shipping a half-working
1465
+ // speech-recognition path.
1466
+ try { window.dispatchEvent(new CustomEvent("openkan:toast", { detail: { kind: "info", message: "Voice input is coming soon." } })); } catch (_err) { /* ignore */ }
1467
+ return;
1468
+ }
1469
+ }
1470
+
1471
+ function onChange(e) {
1472
+ if (!state.root) return;
1473
+ const sel = e.target?.closest?.("[data-chat-select]");
1474
+ if (!sel) return;
1475
+ const key = sel.getAttribute("data-chat-select");
1476
+ const val = sel.value;
1477
+ if (key === "session") {
1478
+ void onPickSession(val);
1479
+ return;
1480
+ }
1481
+ if (key === "model" || key === "effort" || key === "permissionMode") {
1482
+ state.selectors = { ...state.selectors, [key]: val };
1483
+ saveJSON(projectStorageKey(STORAGE_KEYS.selectors), state.selectors);
1484
+ }
1485
+ }
1486
+
1487
+ function onKeyDown(e) {
1488
+ if (!state.root) return;
1489
+ if (e.target?.matches?.("[data-chat-resize]")) {
1490
+ const step = e.shiftKey ? 48 : 16;
1491
+ if (e.key === "ArrowLeft") { e.preventDefault(); setSidebarWidth(state.width + step); return; }
1492
+ if (e.key === "ArrowRight") { e.preventDefault(); setSidebarWidth(state.width - step); return; }
1493
+ if (e.key === "Home") { e.preventDefault(); setSidebarWidth(320); return; }
1494
+ if (e.key === "End") { e.preventDefault(); setSidebarWidth(640); return; }
1495
+ }
1496
+ if (e.target?.id === "chat-sidebar-input") {
1497
+ // IME composition guard — do not send mid-composition.
1498
+ if (e.isComposing) return;
1499
+ if (e.key === "Enter" && !e.shiftKey) {
1500
+ e.preventDefault();
1501
+ void onSend();
1502
+ } else if (e.key === "Escape") {
1503
+ e.preventDefault();
1504
+ (e.target).blur?.();
1505
+ }
1506
+ return;
1507
+ }
1508
+ // Escape inside the transcript collapses any open chip.
1509
+ if (e.key === "Escape") {
1510
+ const open = state.root.querySelectorAll('.chat-chip[aria-expanded="true"]');
1511
+ for (const c of open) {
1512
+ const details = c.querySelector(".chat-chip-details");
1513
+ if (details) details.hidden = true;
1514
+ c.setAttribute("aria-expanded", "false");
1515
+ }
1516
+ }
1517
+ }
1518
+
1519
+ function draftKey() { return `${projectStorageKey(STORAGE_KEYS.draft)}:${state.currentSessionId || "new"}`; }
1520
+ function saveDraft() {
1521
+ const input = state.root?.querySelector("#chat-sidebar-input");
1522
+ if (input) saveJSON(draftKey(), { text: input.value, tasks: [...state.taskMentions.values()] });
1523
+ }
1524
+ function restoreDraft() {
1525
+ const draft = loadJSON(draftKey());
1526
+ const input = state.root?.querySelector("#chat-sidebar-input");
1527
+ if (input) input.value = typeof draft?.text === "string" ? draft.text : "";
1528
+ state.taskMentions = new Map((Array.isArray(draft?.tasks) ? draft.tasks : []).map(normaliseDroppedTask).filter(Boolean).map(task => [task.id, task]));
1529
+ renderTaskMentionTray(); autoResize(); updateAbortButton();
1530
+ }
1531
+ function composerFeedback(message = "", error = false) {
1532
+ const node = state.root?.querySelector("#chat-sidebar-feedback");
1533
+ if (!node) return;
1534
+ node.textContent = message; node.hidden = !message;
1535
+ node.classList.toggle("is-error", error);
1536
+ }
1537
+ function onInput(e) {
1538
+ if (!state.root || e.target?.id !== "chat-sidebar-input") return;
1539
+ autoResize(); saveDraft(); updateAbortButton();
1540
+ }
1541
+
1542
+ function autoResize() {
1543
+ if (!state.root) return;
1544
+ const ta = state.root.querySelector("#chat-sidebar-input");
1545
+ if (!ta) return;
1546
+ // Reset to a single line, then expand up to ~6 lines. Past that we
1547
+ // let the textarea scroll internally.
1548
+ ta.style.height = "auto";
1549
+ const maxH = Math.min(200, window.innerHeight * .25);
1550
+ const next = Math.min(ta.scrollHeight, maxH);
1551
+ ta.style.height = next + "px";
1552
+ ta.style.overflowY = ta.scrollHeight > maxH ? "auto" : "hidden";
1553
+ }
1554
+
1555
+ /** Capture-phase Cmd/Ctrl+K handler — focuses the composer when the
1556
+ * sidebar is open. Registered at load time (before keyboard.js attaches
1557
+ * its own listener, which fires on the same node afterwards). Calling
1558
+ * stopImmediatePropagation prevents keyboard.js from opening the
1559
+ * command palette when the user is in chat mode. */
1560
+ function onGlobalKey(e) {
1561
+ const isMod = e.metaKey || e.ctrlKey;
1562
+ if (!isMod) return;
1563
+ const k = (e.key || "").toLowerCase();
1564
+ if (k !== "l" || !e.shiftKey) return;
1565
+ if (!state.open || !state.mounted) return;
1566
+ const composer = state.root?.querySelector("#chat-sidebar-input");
1567
+ if (!composer) return;
1568
+ e.preventDefault();
1569
+ e.stopPropagation();
1570
+ e.stopImmediatePropagation();
1571
+ composer.focus();
1572
+ try { composer.setSelectionRange(composer.value.length, composer.value.length); } catch (_err) { /* ignore */ }
1573
+ }
1574
+ // Register at load time so we win the registration-order tiebreaker
1575
+ // against keyboard.js (which attaches its own capture-phase listener
1576
+ // later in the page load sequence).
1577
+ window.addEventListener("keydown", onGlobalKey, { capture: true });
1578
+
1579
+ async function copyToClipboard(ts) {
1580
+ const turn = state.transcript.find((t) => t.ts === ts);
1581
+ if (!turn) return;
1582
+ try { await navigator.clipboard.writeText(turn.content || ""); composerFeedback("Message copied."); } catch (_err) { composerFeedback("Could not copy. Select the message text and copy it manually.", true); }
1583
+ }
1584
+
1585
+ async function retryLastTurn() {
1586
+ // Re-submit the last user turn (if any) using the current selectors.
1587
+ const lastUser = [...state.transcript].reverse().find((t) => t.role === "user");
1588
+ if (!lastUser) return;
1589
+ if (!state.root) return;
1590
+ const input = state.root.querySelector("#chat-sidebar-input");
1591
+ if (state.inFlight) return;
1592
+ if (input) input.value = lastUser.content || "";
1593
+ state.taskMentions = new Map((lastUser.taskMentions || []).map(task => [task.id, task]));
1594
+ void onSend();
1595
+ }
1596
+
1597
+ async function onSend() {
1598
+ if (!state.root) return;
1599
+ const input = state.root.querySelector("#chat-sidebar-input");
1600
+ if (!input) return;
1601
+ const message = (input.value || "").trim();
1602
+ const taskMentions = [...state.taskMentions.values()];
1603
+ const selectors = { ...state.selectors };
1604
+ if ((!message && taskMentions.length === 0) || state.inFlight) return;
1605
+
1606
+ const epoch = ++state.requestEpoch;
1607
+ let accepted = false;
1608
+ composerFeedback();
1609
+ state.inFlight = true;
1610
+ updateAbortButton();
1611
+ input.value = "";
1612
+ state.taskMentions.clear();
1613
+ saveDraft();
1614
+ renderTaskMentionTray();
1615
+ autoResize();
1616
+
1617
+ // Optimistic local-only user turn so the UI shows it immediately.
1618
+ // The HTTP response (or SSE) will deliver the canonical turn; the dedup
1619
+ // helper in appendTurnIfNew keeps the transcript from double-counting.
1620
+ const localTs = new Date().toISOString();
1621
+ state.transcript.push({
1622
+ role: "user",
1623
+ content: message,
1624
+ taskMentions,
1625
+ ts: localTs,
1626
+ agent: selectors.agent,
1627
+ model: selectors.model,
1628
+ effort: selectors.effort,
1629
+ permissionMode: selectors.permissionMode,
1630
+ __status: "sending",
1631
+ });
1632
+ state.liveChips = [];
1633
+ state.scrolledUp = false;
1634
+ await renderTranscript();
1635
+ updateLiveStatus({ phase: "thinking", label: "Starting Claude Code" });
1636
+
1637
+ state.abortController = new AbortController();
1638
+ try {
1639
+ const a = api();
1640
+ if (!a) throw new Error("API not ready");
1641
+ const result = await a(
1642
+ "POST",
1643
+ "/api/chat/send",
1644
+ {
1645
+ sessionId: state.currentSessionId || undefined,
1646
+ message,
1647
+ taskMentions,
1648
+ agent: selectors.agent,
1649
+ model: selectors.model,
1650
+ effort: selectors.effort,
1651
+ permissionMode: selectors.permissionMode,
1652
+ },
1653
+ { signal: state.abortController.signal },
1654
+ );
1655
+ if (epoch !== state.requestEpoch) return;
1656
+ if (result?.sessionId) {
1657
+ state.currentSessionId = result.sessionId;
1658
+ saveString(projectStorageKey(STORAGE_KEYS.lastSession), state.currentSessionId);
1659
+ // A newly created session could not subscribe before its ID existed.
1660
+ // Subscribe as soon as the server acknowledges it, while the turn runs.
1661
+ startSessionStream();
1662
+ saveDraft();
1663
+ }
1664
+ if (result?.accepted) {
1665
+ accepted = true;
1666
+ state.transcript = state.transcript.filter((t) => t.ts !== localTs);
1667
+ appendTurnIfNew(result.userTurn);
1668
+ await renderTranscript();
1669
+ updateLiveStatus({ phase: "thinking", label: "Claude is thinking" });
1670
+ void pollForCompletion(state.currentSessionId);
1671
+ } else {
1672
+ state.transcript = state.transcript.filter((t) => t.ts !== localTs);
1673
+ appendTurnIfNew(result?.userTurn);
1674
+ appendTurnIfNew(result?.assistantTurn);
1675
+ await renderTranscript();
1676
+ removeStreamingIndicator();
1677
+ }
1678
+ bindChipChips();
1679
+ await refreshSessions();
1680
+ } catch (err) {
1681
+ if (epoch !== state.requestEpoch) return;
1682
+ state.transcript = state.transcript.filter(turn => turn.ts !== localTs);
1683
+ input.value = input.value ? `${message}\n\n${input.value}` : message;
1684
+ for (const task of taskMentions) state.taskMentions.set(task.id, task);
1685
+ saveDraft(); renderTaskMentionTray(); autoResize();
1686
+ removeStreamingIndicator();
1687
+ composerFeedback(`Message not sent. ${err?.message || "Check your connection"}. Your draft is preserved.`, true);
1688
+ await renderTranscript();
1689
+ } finally {
1690
+ if (epoch === state.requestEpoch) {
1691
+ if (!accepted) state.inFlight = false;
1692
+ state.abortController = null;
1693
+ updateAbortButton();
1694
+ input.focus();
1695
+ }
1696
+ }
1697
+ }
1698
+
1699
+ async function pollForCompletion(sessionId) {
1700
+ const epoch = state.requestEpoch;
1701
+ for (let attempt = 0; attempt < 360 && sessionId === state.currentSessionId && epoch === state.requestEpoch && state.inFlight; attempt += 1) {
1702
+ await new Promise(resolve => setTimeout(resolve, attempt < 24 ? 750 : 5000));
1703
+ if (epoch !== state.requestEpoch || !state.inFlight) return;
1704
+ const data = await fetchSession(sessionId);
1705
+ if (epoch !== state.requestEpoch || sessionId !== state.currentSessionId) return;
1706
+ const last = data?.turns?.at?.(-1);
1707
+ if (last?.role === "assistant" || last?.role === "system") {
1708
+ state.transcript = data.turns;
1709
+ state.inFlight = false;
1710
+ updateAbortButton(); removeStreamingIndicator();
1711
+ await renderTranscript({ completionTs: last.ts || "" });
1712
+ bindChipChips(); stopSessionStream();
1713
+ return;
1714
+ }
1715
+ }
1716
+ }
1717
+
1718
+ async function onAbort() {
1719
+ if (!state.currentSessionId || !state.inFlight) return;
1720
+ composerFeedback("Stopping the agent…");
1721
+ try {
1722
+ await api()("POST", `/api/chat/sessions/${encodeURIComponent(state.currentSessionId)}/abort`);
1723
+ composerFeedback("Stop requested. Waiting for the agent to finish.");
1724
+ } catch (error) { composerFeedback(`Could not stop: ${error.message}. Try Stop again.`, true); }
1725
+ }
1726
+
1727
+ async function onNewSession() {
1728
+ if (state.inFlight) return;
1729
+ saveDraft(); ++state.requestEpoch;
1730
+ state.currentSessionId = "";
1731
+ saveString(projectStorageKey(STORAGE_KEYS.lastSession), "");
1732
+ state.transcript = [];
1733
+ restoreDraft(); composerFeedback();
1734
+ populateSelectors();
1735
+ stopSessionStream();
1736
+ await renderTranscript();
1737
+ }
1738
+
1739
+ async function onArchive() {
1740
+ if (!state.currentSessionId || state.inFlight) return;
1741
+ saveDraft();
1742
+ await deleteSession(state.currentSessionId);
1743
+ state.currentSessionId = "";
1744
+ state.transcript = [];
1745
+ saveString(projectStorageKey(STORAGE_KEYS.lastSession), "");
1746
+ stopSessionStream();
1747
+ await refreshSessions();
1748
+ populateSelectors();
1749
+ await renderTranscript();
1750
+ }
1751
+
1752
+ async function onPickSession(value) {
1753
+ if (state.inFlight) return;
1754
+ saveDraft(); ++state.requestEpoch;
1755
+ if (value === "__new__") return onNewSession();
1756
+ state.currentSessionId = value;
1757
+ saveString(projectStorageKey(STORAGE_KEYS.lastSession), value);
1758
+ restoreDraft(); composerFeedback();
1759
+ const epoch = state.requestEpoch;
1760
+ state.transcript = [];
1761
+ await renderTranscript();
1762
+ const data = await fetchSession(value);
1763
+ if (epoch !== state.requestEpoch) return;
1764
+ if (!data) composerFeedback("Could not load this chat. Choose it again to retry.", true);
1765
+ if (data && Array.isArray(data.turns)) {
1766
+ state.transcript = data.turns;
1767
+ state.inFlight = data.running === true;
1768
+ // Restore selectors from the most recent assistant turn when
1769
+ // available so the composer matches the saved session state.
1770
+ const lastAssistant = [...data.turns].reverse().find((t) => t.role === "assistant");
1771
+ if (lastAssistant) {
1772
+ if (lastAssistant.agent) state.selectors.agent = lastAssistant.agent;
1773
+ if (lastAssistant.model) state.selectors.model = lastAssistant.model;
1774
+ if (lastAssistant.effort) state.selectors.effort = lastAssistant.effort;
1775
+ if (lastAssistant.permissionMode) state.selectors.permissionMode = lastAssistant.permissionMode;
1776
+ saveJSON(projectStorageKey(STORAGE_KEYS.selectors), state.selectors);
1777
+ }
1778
+ populateSelectors();
1779
+ await renderTranscript();
1780
+ bindChipChips();
1781
+ }
1782
+ startSessionStream(); updateAbortButton();
1783
+ if (state.inFlight) { updateLiveStatus({ label: "Agent is working" }); void pollForCompletion(value); }
1784
+ }
1785
+
1786
+ function updateAbortButton() {
1787
+ if (!state.root) return;
1788
+ const send = state.root.querySelector("#chat-sidebar-send");
1789
+ const abort = state.root.querySelector("#chat-sidebar-abort");
1790
+ const input = state.root.querySelector("#chat-sidebar-input");
1791
+ if (send) send.disabled = state.inFlight || (!input?.value.trim() && state.taskMentions.size === 0);
1792
+ if (abort) abort.disabled = !state.currentSessionId;
1793
+ for (const button of state.root.querySelectorAll('[data-chat-action="new"], [data-chat-action="open-session-menu"]')) {
1794
+ button.disabled = state.inFlight;
1795
+ button.title = state.inFlight ? "Stop the current response before switching chats" : "";
1796
+ }
1797
+ if (input) input.placeholder = state.inFlight ? "Draft your next message…" : "Ask about this project…";
1798
+ if (state.inFlight) {
1799
+ if (send) send.hidden = true;
1800
+ if (abort) abort.hidden = false;
1801
+ } else {
1802
+ if (send) send.hidden = false;
1803
+ if (abort) abort.hidden = true;
1804
+ }
1805
+ }
1806
+
1807
+ function toggleActivity() {
1808
+ if (!state.root) return;
1809
+ state.activityOpen = !state.activityOpen;
1810
+ const section = state.root.querySelector("#chat-sidebar-activity");
1811
+ if (section) {
1812
+ section.hidden = !state.activityOpen;
1813
+ section.classList.toggle("chat-sidebar__activity--open", state.activityOpen);
1814
+ }
1815
+ if (state.activityOpen) {
1816
+ const target = state.root.querySelector("#chat-sidebar-claude-root");
1817
+ if (target && window.OpenKanClaude && window.OpenKanClaude.mount) {
1818
+ window.OpenKanClaude.mount(target);
1819
+ }
1820
+ } else if (window.OpenKanClaude && window.OpenKanClaude.unmount) {
1821
+ window.OpenKanClaude.unmount();
1822
+ }
1823
+ }
1824
+
1825
+ /* ----------------------------------------------------------------------
1826
+ * Popovers (model picker, attach menu, session list, tab popovers)
1827
+ *
1828
+ * Popovers are siblings of the composer inside `#chat-sidebar-popover-mount`.
1829
+ * They are absolutely positioned via inline `top` / `left` derived from
1830
+ * the trigger element's `getBoundingClientRect()` and clamped to the
1831
+ * sidebar's visible bounds. Only one popover is open at a time; opening
1832
+ * a new one closes the previous.
1833
+ * -------------------------------------------------------------------- */
1834
+
1835
+ /** Lazily create a popover container. Returns the element. */
1836
+ function ensurePopover(id, className) {
1837
+ if (!state.root) return null;
1838
+ const mount = state.root.querySelector("#chat-sidebar-popover-mount");
1839
+ if (!mount) return null;
1840
+ let node = mount.querySelector(`#${id}`);
1841
+ if (!node) {
1842
+ node = document.createElement("div");
1843
+ node.id = id;
1844
+ node.className = `chat-sidebar__popover ${className || ""}`.trim();
1845
+ node.hidden = true;
1846
+ node.setAttribute("role", "dialog");
1847
+ mount.appendChild(node);
1848
+ }
1849
+ return node;
1850
+ }
1851
+
1852
+ /** Close any open popover. */
1853
+ function closePopover() {
1854
+ if (!state.root) return;
1855
+ const mount = state.root.querySelector("#chat-sidebar-popover-mount");
1856
+ if (!mount) return;
1857
+ for (const node of mount.querySelectorAll(".chat-sidebar__popover")) {
1858
+ node.hidden = true;
1859
+ }
1860
+ // Reset aria-expanded on any trigger we opened.
1861
+ for (const trigger of state.root.querySelectorAll("[data-popover-open='1']")) {
1862
+ trigger.setAttribute("aria-expanded", "false");
1863
+ trigger.removeAttribute("data-popover-open");
1864
+ }
1865
+ state.popoverId = null;
1866
+ }
1867
+
1868
+ /** Anchor an already-built popover within the sidebar's coordinate space. */
1869
+ function anchorPopover(popover, trigger) {
1870
+ const sidebarRect = state.root?.getBoundingClientRect();
1871
+ if (!popover || !trigger || !sidebarRect) return;
1872
+ popover.hidden = false;
1873
+ const triggerRect = trigger.getBoundingClientRect();
1874
+ const popRect = popover.getBoundingClientRect();
1875
+ const inset = 8;
1876
+ let left = triggerRect.left - sidebarRect.left;
1877
+ let top = triggerRect.bottom - sidebarRect.top + 6;
1878
+ left = Math.max(inset, Math.min(left, sidebarRect.width - popRect.width - inset));
1879
+ if (top + popRect.height > sidebarRect.height - inset) {
1880
+ top = Math.max(inset, triggerRect.top - sidebarRect.top - popRect.height - 6);
1881
+ }
1882
+ popover.style.top = `${top}px`;
1883
+ popover.style.left = `${left}px`;
1884
+ trigger.setAttribute("aria-expanded", "true");
1885
+ trigger.setAttribute("data-popover-open", "1");
1886
+ }
1887
+
1888
+ /* ----------------------------------------------------------------------
1889
+ * Separate model and effort pickers. Compact controls are faster to scan
1890
+ * and avoid mixing a model choice with independent reasoning settings.
1891
+ * -------------------------------------------------------------------- */
1892
+
1893
+ async function fetchPickerOptions() {
1894
+ if (state.pickerOptions) return state.pickerOptions;
1895
+ const a = api();
1896
+ if (!a) return null;
1897
+ try {
1898
+ const data = await a("GET", "/api/chat/picker-options");
1899
+ if (!data || !Array.isArray(data.models)) return null;
1900
+ state.pickerOptions = data;
1901
+ return data;
1902
+ } catch (_err) {
1903
+ return null;
1904
+ }
1905
+ }
1906
+
1907
+ function openAgentPicker() {
1908
+ const trigger = state.root?.querySelector(".chat-sidebar__composer-agent");
1909
+ const popover = ensurePopover("chat-sidebar-agent-popover");
1910
+ if (!trigger || !popover) return;
1911
+ if (state.popoverId === popover.id) { closePopover(); return; }
1912
+ closePopover();
1913
+ const agents = state.pickerOptions?.agents || [
1914
+ { id: "openkan", label: "OpenKan", description: "Planning, structure, and project management" },
1915
+ { id: "default", label: "Claude Code", description: "General-purpose assistant" },
1916
+ ];
1917
+ popover.innerHTML = `<section class="chat-sidebar__popover-section"><h3 class="chat-sidebar__popover-heading">Agent</h3><p class="chat-sidebar__popover-note">Choose who handles your next message. Model and effort are separate settings.</p><ul class="chat-sidebar__popover-list">${agents.map(agent => `<li><label class="${agent.id === state.selectors.agent ? "is-active" : ""}"><input type="radio" name="chat-picker-agent" value="${esc(agent.id)}" ${agent.id === state.selectors.agent ? "checked" : ""} /><span class="chat-agent-option"><strong>${esc(agent.label)}</strong><small>${esc(agent.description)}</small></span></label></li>`).join("")}</ul></section>`;
1918
+ state.popoverId = popover.id; anchorPopover(popover, trigger);
1919
+ popover.addEventListener("change", onPickerChange);
1920
+ popover.querySelector("input:checked, input")?.focus();
1921
+ }
1922
+
1923
+ async function openModelPicker() {
1924
+ if (!state.root) return;
1925
+ const trigger = state.root.querySelector(".chat-sidebar__composer-model");
1926
+ if (!trigger) return;
1927
+ const popover = ensurePopover("chat-sidebar-model-popover");
1928
+ if (!popover) return;
1929
+ if (state.popoverId === popover.id) {
1930
+ closePopover();
1931
+ return;
1932
+ }
1933
+ closePopover();
1934
+ const opts = state.pickerOptions;
1935
+ const models = [...new Map([...(opts?.models?.length ? opts.models : state.models.map(id => ({ id, label: id }))), ...(state.selectors.model !== "default" ? [{ id: state.selectors.model, label: state.selectors.model }] : [])].filter(model => model.id !== "default").map(model => [model.id, model])).values()];
1936
+ const modelId = state.selectors.model || "default";
1937
+
1938
+ popover.innerHTML = `
1939
+ <section class="chat-sidebar__popover-section" data-section="model">
1940
+ <h3 class="chat-sidebar__popover-heading">Model</h3>
1941
+ <ul class="chat-sidebar__popover-list">
1942
+ ${modelRadio("default", "Default", modelId === "default")}
1943
+ ${models.map((m) => modelRadio(m.id, m.label || m.id, m.id === modelId)).join("")}
1944
+ </ul>
1945
+ </section>
1946
+ `;
1947
+ state.popoverId = popover.id;
1948
+ // Render before measuring so getBoundingClientRect is accurate.
1949
+ anchorPopover(popover, trigger);
1950
+ popover.addEventListener("change", onPickerChange);
1951
+ popover.querySelector("input:checked, input, button")?.focus();
1952
+ }
1953
+
1954
+ async function openEffortPicker() {
1955
+ if (!state.root) return;
1956
+ const trigger = state.root.querySelector(".chat-sidebar__composer-effort");
1957
+ const popover = ensurePopover("chat-sidebar-effort-popover");
1958
+ if (!trigger || !popover) return;
1959
+ if (state.popoverId === popover.id) { closePopover(); return; }
1960
+ closePopover();
1961
+ const opts = state.pickerOptions;
1962
+ const effort = state.selectors.effort || "high";
1963
+ popover.innerHTML = `<section class="chat-sidebar__popover-section"><h3 class="chat-sidebar__popover-heading">Reasoning effort</h3><p class="chat-sidebar__popover-note">Higher effort gives the agent more time to reason before responding.</p><ul class="chat-sidebar__popover-list">${(opts?.efforts || EFFORT_OPTIONS).map((e) => effortRadio(e, e, e === effort)).join("")}</ul></section>`;
1964
+ state.popoverId = popover.id;
1965
+ anchorPopover(popover, trigger);
1966
+ popover.addEventListener("change", onPickerChange);
1967
+ popover.querySelector("input:checked, input, button")?.focus();
1968
+ }
1969
+
1970
+ function modelRadio(value, label, checked) {
1971
+ return `
1972
+ <li>
1973
+ <label class="${checked ? "is-active" : ""}">
1974
+ <input type="radio" name="chat-picker-model" value="${esc(value)}" ${checked ? "checked" : ""} />
1975
+ <span>${esc(label)}</span>
1976
+ </label>
1977
+ </li>`;
1978
+ }
1979
+ function effortRadio(value, label, checked) {
1980
+ return `
1981
+ <li>
1982
+ <label class="${checked ? "is-active" : ""}">
1983
+ <input type="radio" name="chat-picker-effort" value="${esc(value)}" ${checked ? "checked" : ""} />
1984
+ <span>${esc(label)}</span>
1985
+ </label>
1986
+ </li>`;
1987
+ }
1988
+ function permRadio(value, label, checked) {
1989
+ return `
1990
+ <li>
1991
+ <label class="${checked ? "is-active" : ""}">
1992
+ <input type="radio" name="chat-picker-perm" value="${esc(value)}" ${checked ? "checked" : ""} />
1993
+ <span>${esc(label)}</span>
1994
+ </label>
1995
+ </li>`;
1996
+ }
1997
+
1998
+ function onPickerChange(e) {
1999
+ if (!state.root) return;
2000
+ const t = e.target;
2001
+ if (!(t instanceof HTMLInputElement)) return;
2002
+ const name = t.name;
2003
+ const value = t.value;
2004
+ if (name === "chat-picker-agent") {
2005
+ state.selectors = { ...state.selectors, agent: value };
2006
+ } else if (name === "chat-picker-model") {
2007
+ state.selectors = { ...state.selectors, model: value };
2008
+ } else if (name === "chat-picker-effort") {
2009
+ state.selectors = { ...state.selectors, effort: value };
2010
+ } else if (name === "chat-picker-perm") {
2011
+ state.selectors = { ...state.selectors, permissionMode: value };
2012
+ } else {
2013
+ return;
2014
+ }
2015
+ saveJSON(projectStorageKey(STORAGE_KEYS.selectors), state.selectors);
2016
+ populateSelectors();
2017
+ closePopover();
2018
+ }
2019
+
2020
+ /* ----------------------------------------------------------------------
2021
+ * + attach menu — New session / Import / Add to planning / Cancel.
2022
+ * -------------------------------------------------------------------- */
2023
+
2024
+ function openAttachMenu() {
2025
+ if (!state.root) return;
2026
+ const trigger = state.root.querySelector(".chat-sidebar__composer-attach");
2027
+ if (!trigger) return;
2028
+ const popover = ensurePopover("chat-sidebar-attach-popover", "chat-sidebar__attach-menu");
2029
+ if (!popover) return;
2030
+ if (state.popoverId === popover.id) {
2031
+ closePopover();
2032
+ return;
2033
+ }
2034
+ closePopover();
2035
+ popover.innerHTML = `
2036
+ <button type="button" data-chat-action="new" data-attach="1">New chat</button>
2037
+ <button type="button" data-chat-action="reference-task" data-attach="1">Reference a task…</button>
2038
+ <button type="button" data-chat-action="add-to-planning" data-attach="1">Create task from draft…</button>
2039
+ <button type="button" data-chat-action="close-attach" data-attach="1">Cancel</button>
2040
+ `;
2041
+ state.popoverId = popover.id;
2042
+ anchorPopover(popover, trigger);
2043
+ }
2044
+
2045
+ async function importFromFile() {
2046
+ const a = api();
2047
+ if (!a) return;
2048
+ try {
2049
+ const input = document.createElement("input");
2050
+ input.type = "file";
2051
+ input.accept = ".md,.mdx,.markdown,.txt,.json";
2052
+ input.addEventListener("change", async () => {
2053
+ const file = input.files?.[0];
2054
+ if (!file) return;
2055
+ const text = await file.text();
2056
+ const res = await a("POST", "/api/import", {
2057
+ body: { content: text, filename: file.name },
2058
+ });
2059
+ closePopover();
2060
+ if (res && res.ok) await refreshSessions();
2061
+ }, { once: true });
2062
+ input.click();
2063
+ } catch (_err) {
2064
+ closePopover();
2065
+ }
2066
+ }
2067
+
2068
+ async function addToPlanning() {
2069
+ const message = state.root?.querySelector("#chat-sidebar-input")?.value.trim();
2070
+ closePopover();
2071
+ if (!message) { composerFeedback("Write a task description in the message box first."); state.root?.querySelector("#chat-sidebar-input")?.focus(); return; }
2072
+ window.OpenKanCreateTask?.openFromChat?.(message);
2073
+ }
2074
+
2075
+ async function openTaskReferencePicker() {
2076
+ const popover = ensurePopover("chat-sidebar-tasks-popover");
2077
+ const trigger = state.root?.querySelector(".chat-sidebar__composer-attach");
2078
+ if (!popover || !trigger) return;
2079
+ closePopover(); state.popoverId = popover.id;
2080
+ popover.innerHTML = '<p class="chat-sidebar__popover-note">Loading tasks…</p>';
2081
+ anchorPopover(popover, trigger);
2082
+ try {
2083
+ const board = await api()("GET", "/api/board");
2084
+ if (state.popoverId !== popover.id) return;
2085
+ const tasks = (board.tasks || []).filter(task => !task.archived);
2086
+ popover.innerHTML = '<label class="chat-task-search">Reference a task<input type="search" placeholder="Search tasks…" aria-label="Search tasks to reference" /></label><div data-task-results></div>';
2087
+ const results = popover.querySelector("[data-task-results]");
2088
+ const render = (query = "") => {
2089
+ results.replaceChildren();
2090
+ const matches = tasks.filter(task => `${task.title} ${task.id}`.toLowerCase().includes(query.toLowerCase()));
2091
+ if (!matches.length) { results.textContent = "No matching tasks."; return; }
2092
+ for (const task of matches) {
2093
+ const button = document.createElement("button");
2094
+ button.type = "button"; button.className = "chat-task-option";
2095
+ button.textContent = task.title; button.title = task.id;
2096
+ button.addEventListener("click", () => { insertTaskMention(task); closePopover(); });
2097
+ results.append(button);
2098
+ }
2099
+ };
2100
+ render(); anchorPopover(popover, trigger);
2101
+ const search = popover.querySelector("input");
2102
+ search.addEventListener("input", () => render(search.value)); search.focus();
2103
+ } catch (error) {
2104
+ if (state.popoverId === popover.id) popover.textContent = `Could not load tasks: ${error.message}. Close and try again.`;
2105
+ }
2106
+ }
2107
+
2108
+ /* ----------------------------------------------------------------------
2109
+ * Session chip menu — quick switcher + New session.
2110
+ * -------------------------------------------------------------------- */
2111
+
2112
+ function openSessionMenu() {
2113
+ if (!state.root) return;
2114
+ const trigger = state.root.querySelector(".chat-sidebar__session-chip");
2115
+ if (!trigger) return;
2116
+ const popover = ensurePopover("chat-sidebar-session-popover");
2117
+ if (!popover) return;
2118
+ if (state.popoverId === popover.id) {
2119
+ closePopover();
2120
+ return;
2121
+ }
2122
+ closePopover();
2123
+ const items = [`<button type="button" data-chat-action="new" data-attach="1">New chat</button>`]
2124
+ .concat((state.sessions || []).slice(0, 20).map((s) =>
2125
+ `<button type="button" data-chat-action="pick-session" data-session-id="${esc(s.id)}" data-attach="1">${esc(s.title || s.id)}</button>`,
2126
+ ));
2127
+ popover.innerHTML = items.join("");
2128
+ state.popoverId = popover.id;
2129
+ anchorPopover(popover, trigger);
2130
+ }
2131
+
2132
+ /* ----------------------------------------------------------------------
2133
+ * Tab popovers — Project / Files / Plugins.
2134
+ * -------------------------------------------------------------------- */
2135
+
2136
+ function openTab(tab) {
2137
+ if (!state.root) return;
2138
+ if (state.activeTab === tab) {
2139
+ closeTab();
2140
+ return;
2141
+ }
2142
+ // "Get desktop app" is a CTA — open the releases page in a new tab
2143
+ // rather than toggling a popover. It is not a real tab state.
2144
+ if (tab === "desktop-app") {
2145
+ try {
2146
+ window.open("https://github.com/PolderLabsVOF/openkan/releases", "_blank", "noopener,noreferrer");
2147
+ } catch (_err) { /* ignore */ }
2148
+ return;
2149
+ }
2150
+ closeTab();
2151
+ const popover = ensurePopover(`chat-sidebar-tab-${tab}-popover`);
2152
+ if (!popover) return;
2153
+ if (tab === "project") {
2154
+ popover.innerHTML = `
2155
+ <h3 class="chat-sidebar__popover-heading">Project</h3>
2156
+ <button type="button" data-chat-action="open-project-picker" data-attach="1">Switch project…</button>
2157
+ <button type="button" data-chat-action="list-sessions" data-attach="1">List sessions in this project</button>
2158
+ `;
2159
+ } else if (tab === "files") {
2160
+ popover.innerHTML = `
2161
+ <h3 class="chat-sidebar__popover-heading">Files</h3>
2162
+ <button type="button" data-chat-action="open-docs" data-attach="1">Open documentation browser</button>
2163
+ <button type="button" data-chat-action="toggle-docs-pane" data-attach="1">Toggle docs pane</button>
2164
+ `;
2165
+ } else if (tab === "plugins") {
2166
+ popover.innerHTML = `
2167
+ <h3 class="chat-sidebar__popover-heading">Plugins</h3>
2168
+ <button type="button" data-chat-action="m1-import" data-attach="1">M1 import</button>
2169
+ <button type="button" data-chat-action="planning-cli" data-attach="1">Planning CLI</button>
2170
+ <button type="button" data-chat-action="agents-catalog" data-attach="1">Agents catalog</button>
2171
+ `;
2172
+ } else {
2173
+ return;
2174
+ }
2175
+ const trigger = state.root.querySelector(`.chat-sidebar__tabs-tab[data-tab="${tab}"]`);
2176
+ state.popoverId = popover.id;
2177
+ anchorPopover(popover, trigger);
2178
+ setActiveTab(tab);
2179
+ }
2180
+
2181
+ function closeTab() {
2182
+ setActiveTab(null);
2183
+ if (state.root) {
2184
+ const mount = state.root.querySelector("#chat-sidebar-popover-mount");
2185
+ if (mount) {
2186
+ for (const node of mount.querySelectorAll('[id^="chat-sidebar-tab-"]')) {
2187
+ node.hidden = true;
2188
+ }
2189
+ }
2190
+ }
2191
+ if (state.popoverId && state.popoverId.startsWith("chat-sidebar-tab-")) {
2192
+ state.popoverId = null;
2193
+ }
2194
+ }
2195
+
2196
+ function setActiveTab(name) {
2197
+ state.activeTab = name;
2198
+ if (!state.root) return;
2199
+ for (const tab of state.root.querySelectorAll(".chat-sidebar__tabs-tab")) {
2200
+ const isActive = tab.getAttribute("data-tab") === name;
2201
+ tab.classList.toggle("chat-sidebar__tabs-tab--active", isActive);
2202
+ tab.setAttribute("aria-selected", isActive ? "true" : "false");
2203
+ }
2204
+ }
2205
+
2206
+ async function onImportFileClick() { await importFromFile(); }
2207
+ async function onAddToPlanningClick() { await addToPlanning(); }
2208
+ function onPickSessionClick(id) { closePopover(); void onPickSession(id); }
2209
+ function onOpenProjectPicker() {
2210
+ // Best-effort: open the path picker (if loaded) or focus the docs
2211
+ // browser command. The action is fire-and-forget.
2212
+ try { window.OpenKanPathPicker?.open?.(); } catch (_err) { /* ignore */ }
2213
+ closePopover();
2214
+ }
2215
+ function onOpenDocs() {
2216
+ try { window.dispatchEvent(new CustomEvent("openkan:open-docs")); } catch (_err) { /* ignore */ }
2217
+ closePopover();
2218
+ }
2219
+ function onToggleDocsPane() {
2220
+ try { window.dispatchEvent(new CustomEvent("openkan:toggle-docs-pane")); } catch (_err) { /* ignore */ }
2221
+ closePopover();
2222
+ }
2223
+ function onM1Import() { void importFromFile(); }
2224
+ function onPlanningCli() {
2225
+ try { window.dispatchEvent(new CustomEvent("openkan:open-planning-cli")); } catch (_err) { /* ignore */ }
2226
+ closePopover();
2227
+ }
2228
+ function onAgentsCatalog() {
2229
+ try { window.dispatchEvent(new CustomEvent("openkan:open-agents-catalog")); } catch (_err) { /* ignore */ }
2230
+ closePopover();
2231
+ }
2232
+ function onListSessions() {
2233
+ closePopover();
2234
+ openSessionMenu();
2235
+ }
2236
+
2237
+ function sidebarWidthFor(value) {
2238
+ const available = Math.max(320, window.innerWidth - 360);
2239
+ return Math.round(Math.max(320, Math.min(value, Math.min(640, available))));
2240
+ }
2241
+
2242
+ function setSidebarWidth(value, persist = true) {
2243
+ state.width = sidebarWidthFor(value);
2244
+ document.documentElement.style.setProperty("--chat-sidebar-width", `${state.width}px`);
2245
+ const handle = state.root?.querySelector("[data-chat-resize]");
2246
+ if (handle) handle.setAttribute("aria-valuenow", String(state.width));
2247
+ if (persist) saveString(STORAGE_KEYS.width, String(state.width));
2248
+ }
2249
+
2250
+ function beginResize(event) {
2251
+ if (event.button !== undefined && event.button !== 0) return;
2252
+ event.preventDefault();
2253
+ const handle = event.currentTarget;
2254
+ state.resizing = true;
2255
+ document.body.classList.add("chat-sidebar-resizing");
2256
+ try { handle.setPointerCapture?.(event.pointerId); } catch (_err) { /* ignore */ }
2257
+ const resize = (move) => setSidebarWidth(move.clientX);
2258
+ const finish = () => {
2259
+ state.resizing = false;
2260
+ document.body.classList.remove("chat-sidebar-resizing");
2261
+ window.removeEventListener("pointermove", resize);
2262
+ window.removeEventListener("pointerup", finish);
2263
+ window.removeEventListener("pointercancel", finish);
2264
+ };
2265
+ window.addEventListener("pointermove", resize);
2266
+ window.addEventListener("pointerup", finish, { once: true });
2267
+ window.addEventListener("pointercancel", finish, { once: true });
2268
+ }
2269
+
2270
+ /* ----------------------------------------------------------------------
2271
+ * Open / close + mount
2272
+ * -------------------------------------------------------------------- */
2273
+ function open() {
2274
+ if (!state.root) return;
2275
+ state.root.hidden = false;
2276
+ state.open = true;
2277
+ document.body.classList.add("chat-sidebar-open");
2278
+ saveString(STORAGE_KEYS.open, "1");
2279
+ autoResize();
2280
+ }
2281
+ function close() {
2282
+ if (!state.root) return;
2283
+ state.root.hidden = true;
2284
+ state.open = false;
2285
+ document.body.classList.remove("chat-sidebar-open");
2286
+ saveString(STORAGE_KEYS.open, "0");
2287
+ }
2288
+ function toggle() { state.open ? close() : open(); }
2289
+ function isOpen() { return state.open; }
2290
+
2291
+ async function refreshSessions() {
2292
+ if (!state.root) return;
2293
+ state.sessions = await fetchSessions();
2294
+ populateSelectors();
2295
+ const meta = state.root.querySelector("#chat-sidebar-meta");
2296
+ if (meta) {
2297
+ meta.textContent = state.sessions.length === 0
2298
+ ? "no sessions"
2299
+ : `${state.sessions.length} session${state.sessions.length === 1 ? "" : "s"}`;
2300
+ }
2301
+ }
2302
+
2303
+ async function mount(rootEl) {
2304
+ if (state.mounted) return;
2305
+ state.root = buildShell();
2306
+ state.mounted = true;
2307
+ state.projectScope = await resolveProjectScope();
2308
+ // Chat mode owns the main canvas, so it must not inherit a previously
2309
+ // closed task-mode rail. `attachWorkspaceMode()` runs before mount(),
2310
+ // therefore this is the authoritative point to reconcile the state.
2311
+ state.open = document.body.classList.contains("workspace-mode-chat")
2312
+ || loadString(STORAGE_KEYS.open) === "1";
2313
+ state.selectors = { ...DEFAULT_SELECTORS, ...(loadJSON(projectStorageKey(STORAGE_KEYS.selectors)) || {}) };
2314
+ const permissionAliases = { "bypass-permissions": "bypassPermissions", "accept-edits": "acceptEdits", default: "bypassPermissions" };
2315
+ state.selectors.permissionMode = permissionAliases[state.selectors.permissionMode] || state.selectors.permissionMode || "bypassPermissions";
2316
+ state.currentSessionId = loadString(projectStorageKey(STORAGE_KEYS.lastSession));
2317
+ [state.models, state.pickerOptions] = await Promise.all([fetchModels(), fetchPickerOptions()]);
2318
+ populateSelectors();
2319
+ bindEvents();
2320
+ bindGlobalDismiss();
2321
+ const savedWidth = Number.parseInt(loadString(STORAGE_KEYS.width), 10);
2322
+ setSidebarWidth(Number.isFinite(savedWidth) ? savedWidth : state.width, false);
2323
+ const resizeHandle = state.root.querySelector("[data-chat-resize]");
2324
+ resizeHandle?.addEventListener("pointerdown", beginResize);
2325
+ if (state.open) open();
2326
+ autoResize();
2327
+ startLive();
2328
+ await refreshSessions();
2329
+ if (state.currentSessionId) {
2330
+ const data = await fetchSession(state.currentSessionId);
2331
+ if (data && Array.isArray(data.turns)) {
2332
+ state.transcript = data.turns;
2333
+ state.inFlight = data.running === true;
2334
+ } else {
2335
+ state.currentSessionId = "";
2336
+ saveString(projectStorageKey(STORAGE_KEYS.lastSession), "");
2337
+ }
2338
+ }
2339
+ await renderTranscript();
2340
+ bindChipChips();
2341
+ syncHeroState();
2342
+ restoreDraft();
2343
+ startSessionStream();
2344
+ if (state.inFlight) { updateLiveStatus({ label: "Agent is working" }); void pollForCompletion(state.currentSessionId); }
2345
+ }
2346
+
2347
+ function unmount() {
2348
+ if (!state.mounted) return;
2349
+ saveDraft(); ++state.requestEpoch; state.inFlight = false;
2350
+ state.dismissController?.abort();
2351
+ stopLive();
2352
+ if (window.OpenKanClaude && window.OpenKanClaude.unmount) {
2353
+ try { window.OpenKanClaude.unmount(); } catch (_err) { /* ignore */ }
2354
+ }
2355
+ if (state.abortController) {
2356
+ try { state.abortController.abort(); } catch (_err) { /* ignore */ }
2357
+ state.abortController = null;
2358
+ }
2359
+ if (state.root) {
2360
+ try { closePopover(); } catch (_err) { /* ignore */ }
2361
+ try { state.root.remove(); } catch (_err) { /* ignore */ }
2362
+ state.root = null;
2363
+ }
2364
+ document.body.classList.remove("chat-sidebar-open", "chat-sidebar-resizing");
2365
+ state.mounted = false;
2366
+ state.open = false;
2367
+ state.transcript = [];
2368
+ state.taskMentions.clear();
2369
+ state.renderedCache.clear();
2370
+ state.pickerOptions = null;
2371
+ state.popoverId = null;
2372
+ state.activeTab = null;
2373
+ }
2374
+
2375
+ // The topbar toggle button: if it exists before mount, wire its click to
2376
+ // toggle(); if not, the app can call `OpenKanChatSidebar.toggle()` directly.
2377
+ function wireTopbarToggle() {
2378
+ const btn = document.getElementById("chat-sidebar-toggle-btn");
2379
+ if (!btn) return;
2380
+ btn.addEventListener("click", () => toggle());
2381
+ }
2382
+
2383
+ // Auto-wire when DOM is ready (chat-sidebar.js is loaded after the body
2384
+ // element so the topbar button is already parsed).
2385
+ if (document.readyState === "loading") {
2386
+ document.addEventListener("DOMContentLoaded", wireTopbarToggle, { once: true });
2387
+ } else {
2388
+ wireTopbarToggle();
2389
+ }
2390
+
2391
+ async function mentionTask(task) {
2392
+ if (!state.mounted) await mount(document.body);
2393
+ open(); insertTaskMention(normaliseDroppedTask(task));
2394
+ composerFeedback("Task referenced. Add a question or send it to discuss this task.");
2395
+ }
2396
+ window.OpenKanChatSidebar = { mount, unmount, toggle, open, close, isOpen, mentionTask };
2397
+ })();