codex-autorunner 0.1.0__py3-none-any.whl

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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1514 @@
1
+ import {
2
+ api,
3
+ flash,
4
+ statusPill,
5
+ confirmModal,
6
+ resolvePath,
7
+ getAuthToken,
8
+ isMobileViewport,
9
+ getUrlParams,
10
+ updateUrlParams,
11
+ } from "./utils.js";
12
+ import { loadState } from "./state.js";
13
+ import { publish } from "./bus.js";
14
+ import { registerAutoRefresh } from "./autoRefresh.js";
15
+ import { CONSTANTS } from "./constants.js";
16
+ import { initVoiceInput } from "./voice.js";
17
+ import { renderTodoPreview } from "./todoPreview.js";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Constants & State
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ const DOC_TYPES = ["todo", "progress", "opinions", "spec", "summary"];
24
+ const CLEARABLE_DOCS = ["todo", "progress", "opinions"];
25
+ const COPYABLE_DOCS = ["spec", "summary"];
26
+ const PASTEABLE_DOCS = ["spec"];
27
+ const CHAT_HISTORY_LIMIT = 8;
28
+
29
+ const docButtons = document.querySelectorAll(".chip[data-doc]");
30
+ let docsCache = { todo: "", progress: "", opinions: "", spec: "", summary: "" };
31
+ let snapshotCache = { exists: false, content: "", state: {} };
32
+ let snapshotBusy = false;
33
+ let activeDoc = "todo";
34
+
35
+ const chatDecoder = new TextDecoder();
36
+ const chatState = Object.fromEntries(
37
+ DOC_TYPES.map((k) => [k, createChatState()])
38
+ );
39
+ const VOICE_TRANSCRIPT_DISCLAIMER_TEXT =
40
+ CONSTANTS.PROMPTS?.VOICE_TRANSCRIPT_DISCLAIMER ||
41
+ "Note: transcribed from user voice. If confusing or possibly inaccurate and you cannot infer the intention please clarify before proceeding.";
42
+
43
+ // Track history navigation position for up/down arrow prompt recall
44
+ let historyNavIndex = -1;
45
+
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // UI Element References
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ const chatUI = {
51
+ status: document.getElementById("doc-chat-status"),
52
+ response: document.getElementById("doc-chat-response"),
53
+ responseWrapper: document.getElementById("doc-chat-response-wrapper"),
54
+ patchMain: document.getElementById("doc-patch-main"),
55
+ patchSummary: document.getElementById("doc-patch-summary"),
56
+ patchBody: document.getElementById("doc-patch-body"),
57
+ patchApply: document.getElementById("doc-patch-apply"),
58
+ patchDiscard: document.getElementById("doc-patch-discard"),
59
+ patchReload: document.getElementById("doc-patch-reload"),
60
+ history: document.getElementById("doc-chat-history"),
61
+ historyDetails: document.getElementById("doc-chat-history-details"),
62
+ historyCount: document.getElementById("doc-chat-history-count"),
63
+ error: document.getElementById("doc-chat-error"),
64
+ input: document.getElementById("doc-chat-input"),
65
+ send: document.getElementById("doc-chat-send"),
66
+ cancel: document.getElementById("doc-chat-cancel"),
67
+ voiceBtn: document.getElementById("doc-chat-voice"),
68
+ voiceStatus: document.getElementById("doc-chat-voice-status"),
69
+ hint: document.getElementById("doc-chat-hint"),
70
+ };
71
+
72
+ const specIssueUI = {
73
+ row: document.getElementById("spec-issue-import"),
74
+ toggle: document.getElementById("spec-issue-import-toggle"),
75
+ inputRow: document.getElementById("spec-issue-input-row"),
76
+ input: document.getElementById("spec-issue-input"),
77
+ button: document.getElementById("spec-issue-import-btn"),
78
+ };
79
+
80
+ const snapshotUI = {
81
+ generate: document.getElementById("snapshot-generate"),
82
+ update: document.getElementById("snapshot-update"),
83
+ regenerate: document.getElementById("snapshot-regenerate"),
84
+ copy: document.getElementById("snapshot-copy"),
85
+ refresh: document.getElementById("snapshot-refresh"),
86
+ };
87
+
88
+ const docActionsUI = {
89
+ standard: document.getElementById("doc-actions-standard"),
90
+ snapshot: document.getElementById("doc-actions-snapshot"),
91
+ ingest: document.getElementById("ingest-spec"),
92
+ clear: document.getElementById("clear-docs"),
93
+ copy: document.getElementById("doc-copy"),
94
+ paste: document.getElementById("spec-paste"),
95
+ };
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Chat State Management
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ function createChatState() {
102
+ return {
103
+ history: [],
104
+ status: "idle",
105
+ statusText: "",
106
+ error: "",
107
+ streamText: "",
108
+ controller: null,
109
+ patch: "",
110
+ };
111
+ }
112
+
113
+ function getChatState(kind = activeDoc) {
114
+ if (!chatState[kind]) {
115
+ chatState[kind] = createChatState();
116
+ }
117
+ return chatState[kind];
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Utilities
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+
124
+ function parseChatPayload(payload) {
125
+ if (!payload) return { response: "" };
126
+ if (typeof payload === "string") return { response: payload };
127
+ if (payload.status && payload.status !== "ok") {
128
+ return { error: payload.detail || "Doc chat failed" };
129
+ }
130
+ return {
131
+ response: payload.agent_message || payload.message || payload.content || "",
132
+ content: payload.content || "",
133
+ patch: payload.patch || "",
134
+ };
135
+ }
136
+
137
+ function parseMaybeJson(raw) {
138
+ try {
139
+ return JSON.parse(raw);
140
+ } catch (err) {
141
+ return raw;
142
+ }
143
+ }
144
+
145
+ function truncateText(text, maxLen) {
146
+ if (!text) return "";
147
+ const normalized = text.replace(/\s+/g, " ").trim();
148
+ return normalized.length > maxLen
149
+ ? normalized.slice(0, maxLen) + "…"
150
+ : normalized;
151
+ }
152
+
153
+ function getDocFromUrl() {
154
+ const params = getUrlParams();
155
+ const kind = params.get("doc");
156
+ if (!kind) return null;
157
+ if (kind === "snapshot") return kind;
158
+ return DOC_TYPES.includes(kind) ? kind : null;
159
+ }
160
+
161
+ /**
162
+ * Render a unified diff with syntax highlighting and line numbers.
163
+ * Returns HTML with colored lines for additions (+), deletions (-),
164
+ * headers (@@), and file paths (--- / +++).
165
+ */
166
+ function renderDiffHtml(diffText) {
167
+ if (!diffText) return "";
168
+ const lines = diffText.split("\n");
169
+ let oldLineNum = 0;
170
+ let newLineNum = 0;
171
+
172
+ const htmlLines = lines.map((line) => {
173
+ // Escape HTML entities
174
+ const escaped = line
175
+ .replace(/&/g, "&")
176
+ .replace(/</g, "&lt;")
177
+ .replace(/>/g, "&gt;");
178
+
179
+ // Parse hunk header to get line numbers
180
+ if (line.startsWith("@@") && line.includes("@@")) {
181
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
182
+ if (match) {
183
+ oldLineNum = parseInt(match[1], 10);
184
+ newLineNum = parseInt(match[2], 10);
185
+ }
186
+ return `<div class="diff-line diff-hunk"><span class="diff-gutter diff-gutter-hunk">···</span><span class="diff-content">${escaped}</span></div>`;
187
+ }
188
+
189
+ // File headers (no line numbers)
190
+ if (line.startsWith("+++") || line.startsWith("---")) {
191
+ return `<div class="diff-line diff-file"><span class="diff-gutter"></span><span class="diff-content">${escaped}</span></div>`;
192
+ }
193
+
194
+ // Addition line
195
+ if (line.startsWith("+")) {
196
+ const lineNum = newLineNum++;
197
+ const content = escaped.substring(1); // Remove the + prefix
198
+ const isEmpty = content.trim() === "";
199
+ const displayContent = isEmpty
200
+ ? `<span class="diff-empty-marker">↵</span>`
201
+ : content;
202
+ return `<div class="diff-line diff-add"><span class="diff-gutter diff-gutter-add">${lineNum}</span><span class="diff-sign">+</span><span class="diff-content">${displayContent}</span></div>`;
203
+ }
204
+
205
+ // Deletion line
206
+ if (line.startsWith("-")) {
207
+ const lineNum = oldLineNum++;
208
+ const content = escaped.substring(1); // Remove the - prefix
209
+ const isEmpty = content.trim() === "";
210
+ const displayContent = isEmpty
211
+ ? `<span class="diff-empty-marker">↵</span>`
212
+ : content;
213
+ return `<div class="diff-line diff-del"><span class="diff-gutter diff-gutter-del">${lineNum}</span><span class="diff-sign">−</span><span class="diff-content">${displayContent}</span></div>`;
214
+ }
215
+
216
+ // Context line (unchanged)
217
+ if (
218
+ line.startsWith(" ") ||
219
+ (line.length > 0 && !line.startsWith("\\") && oldLineNum > 0)
220
+ ) {
221
+ const oLine = oldLineNum++;
222
+ newLineNum += 1;
223
+ const content = escaped.startsWith(" ") ? escaped.substring(1) : escaped;
224
+ return `<div class="diff-line diff-ctx"><span class="diff-gutter diff-gutter-ctx">${oLine}</span><span class="diff-sign"> </span><span class="diff-content">${content}</span></div>`;
225
+ }
226
+
227
+ // Other lines (like "")
228
+ return `<div class="diff-line diff-meta"><span class="diff-gutter"></span><span class="diff-content diff-note">${escaped}</span></div>`;
229
+ });
230
+
231
+ return `<div class="diff-view">${htmlLines.join("")}</div>`;
232
+ }
233
+
234
+ function autoResizeTextarea(textarea) {
235
+ textarea.style.height = "auto";
236
+ textarea.style.height = textarea.scrollHeight + "px";
237
+ }
238
+
239
+ function getDocTextarea() {
240
+ return document.getElementById("doc-content");
241
+ }
242
+
243
+ function updateCopyButton(button, text, disabled = false) {
244
+ if (!button) return;
245
+ const hasText = Boolean((text || "").trim());
246
+ button.disabled = disabled || !hasText;
247
+ }
248
+
249
+ function getDocCopyText(kind = activeDoc) {
250
+ const textarea = getDocTextarea();
251
+ if (textarea && activeDoc === kind) {
252
+ return textarea.value || "";
253
+ }
254
+ if (kind === "snapshot") {
255
+ return snapshotCache.content || "";
256
+ }
257
+ return docsCache[kind] || "";
258
+ }
259
+
260
+ function updateStandardActionButtons(kind = activeDoc) {
261
+ if (docActionsUI.copy) {
262
+ const canCopy = COPYABLE_DOCS.includes(kind);
263
+ docActionsUI.copy.classList.toggle("hidden", !canCopy);
264
+ updateCopyButton(docActionsUI.copy, canCopy ? getDocCopyText(kind) : "");
265
+ }
266
+ if (docActionsUI.paste) {
267
+ const canPaste = PASTEABLE_DOCS.includes(kind);
268
+ docActionsUI.paste.classList.toggle("hidden", !canPaste);
269
+ }
270
+ }
271
+
272
+ async function copyDocToClipboard(kind = activeDoc) {
273
+ const text = getDocCopyText(kind);
274
+ if (!text.trim()) return;
275
+ try {
276
+ if (navigator.clipboard?.writeText) {
277
+ await navigator.clipboard.writeText(text);
278
+ flash("Copied to clipboard");
279
+ return;
280
+ }
281
+ } catch {
282
+ // fall through
283
+ }
284
+
285
+ let temp = null;
286
+ try {
287
+ temp = document.createElement("textarea");
288
+ temp.value = text;
289
+ temp.setAttribute("readonly", "");
290
+ temp.style.position = "fixed";
291
+ temp.style.top = "-9999px";
292
+ temp.style.opacity = "0";
293
+ document.body.appendChild(temp);
294
+ temp.select();
295
+ const ok = document.execCommand("copy");
296
+ flash(ok ? "Copied to clipboard" : "Copy failed");
297
+ } catch {
298
+ flash("Copy failed");
299
+ } finally {
300
+ if (temp && temp.parentNode) {
301
+ temp.parentNode.removeChild(temp);
302
+ }
303
+ }
304
+ }
305
+
306
+ async function pasteSpecFromClipboard() {
307
+ if (!PASTEABLE_DOCS.includes(activeDoc)) return;
308
+ const textarea = getDocTextarea();
309
+ if (!textarea) return;
310
+ try {
311
+ if (!navigator.clipboard?.readText) {
312
+ flash("Paste not supported in this browser", "error");
313
+ return;
314
+ }
315
+ const text = await navigator.clipboard.readText();
316
+ if (!text) {
317
+ flash("Clipboard is empty", "error");
318
+ return;
319
+ }
320
+ textarea.value = text;
321
+ textarea.focus();
322
+ updateStandardActionButtons("spec");
323
+ flash("SPEC replaced from clipboard");
324
+ } catch {
325
+ flash("Paste failed", "error");
326
+ }
327
+ }
328
+
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ // Chat UI Rendering
331
+ // ─────────────────────────────────────────────────────────────────────────────
332
+
333
+ async function applyDocUpdateFromChat(kind, content) {
334
+ if (!content) return false;
335
+ const textarea = getDocTextarea();
336
+ const viewingSameDoc = activeDoc === kind;
337
+ if (viewingSameDoc && textarea) {
338
+ const cached = docsCache[kind] || "";
339
+ if (textarea.value !== cached) {
340
+ const ok = await confirmModal(
341
+ `You have unsaved ${kind.toUpperCase()} edits. Overwrite with chat result?`
342
+ );
343
+ if (!ok) {
344
+ flash(
345
+ `Kept your unsaved ${kind.toUpperCase()} edits; chat result not applied.`
346
+ );
347
+ return false;
348
+ }
349
+ }
350
+ }
351
+
352
+ docsCache[kind] = content;
353
+ if (viewingSameDoc && textarea) {
354
+ textarea.value = content;
355
+ document.getElementById(
356
+ "doc-status"
357
+ ).textContent = `Editing ${kind.toUpperCase()}`;
358
+ }
359
+ if (viewingSameDoc) {
360
+ updateStandardActionButtons(kind);
361
+ }
362
+ publish("docs:updated", { kind, content });
363
+ if (kind === "todo") {
364
+ renderTodoPreview(content);
365
+ loadState({ notify: false }).catch(() => {});
366
+ }
367
+ return true;
368
+ }
369
+
370
+ function renderChat(kind = activeDoc) {
371
+ if (kind !== activeDoc) return;
372
+ const state = getChatState(kind);
373
+ const latest = state.history[0];
374
+ const isRunning = state.status === "running";
375
+ const hasError = !!state.error;
376
+
377
+ // Update status pill
378
+ const pillState = isRunning
379
+ ? "running"
380
+ : state.status === "error"
381
+ ? "error"
382
+ : "idle";
383
+ statusPill(chatUI.status, pillState);
384
+
385
+ // Update input state
386
+ chatUI.send.disabled = isRunning;
387
+ chatUI.input.disabled = isRunning;
388
+ chatUI.cancel.classList.toggle("hidden", !isRunning);
389
+ if (chatUI.voiceBtn) {
390
+ chatUI.voiceBtn.disabled =
391
+ isRunning && !chatUI.voiceBtn.classList.contains("voice-retry");
392
+ chatUI.voiceBtn.classList.toggle("disabled", chatUI.voiceBtn.disabled);
393
+ if (typeof chatUI.voiceBtn.setAttribute === "function") {
394
+ chatUI.voiceBtn.setAttribute(
395
+ "aria-disabled",
396
+ chatUI.voiceBtn.disabled ? "true" : "false"
397
+ );
398
+ }
399
+ }
400
+
401
+ // Update hint text - show status inline when running
402
+ if (isRunning) {
403
+ const statusText = state.statusText || "processing";
404
+ chatUI.hint.textContent = statusText;
405
+ chatUI.hint.classList.add("loading");
406
+ } else {
407
+ const sendHint = isMobileViewport()
408
+ ? "Tap Send to send · Enter for newline"
409
+ : "Cmd+Enter / Ctrl+Enter to send · Enter for newline";
410
+ chatUI.hint.textContent = sendHint;
411
+ chatUI.hint.classList.remove("loading");
412
+ }
413
+
414
+ // Handle error display
415
+ if (hasError) {
416
+ chatUI.error.textContent = state.error;
417
+ chatUI.error.classList.remove("hidden");
418
+ } else {
419
+ chatUI.error.textContent = "";
420
+ chatUI.error.classList.add("hidden");
421
+ }
422
+
423
+ // Compute response text - only show actual content, not placeholders
424
+ let responseText = "";
425
+ if (isRunning && state.streamText) {
426
+ responseText = state.streamText;
427
+ } else if (!isRunning && latest && (latest.response || latest.error)) {
428
+ responseText = latest.response || latest.error;
429
+ }
430
+
431
+ // Show response wrapper only when there's real content or an error
432
+ const showResponse = !!responseText || hasError;
433
+ chatUI.responseWrapper.classList.toggle("hidden", !showResponse);
434
+ chatUI.response.textContent = responseText;
435
+ chatUI.response.classList.toggle("streaming", isRunning && state.streamText);
436
+
437
+ const hasPatch = !!(state.patch && state.patch.trim());
438
+ if (chatUI.patchMain) {
439
+ chatUI.patchMain.classList.toggle("hidden", !hasPatch);
440
+ // Use syntax-highlighted diff rendering
441
+ chatUI.patchBody.innerHTML = hasPatch
442
+ ? renderDiffHtml(state.patch)
443
+ : "(no patch)";
444
+ chatUI.patchSummary.textContent = latest?.response || state.error || "";
445
+ if (chatUI.patchApply) chatUI.patchApply.disabled = isRunning || !hasPatch;
446
+ if (chatUI.patchDiscard)
447
+ chatUI.patchDiscard.disabled = isRunning || !hasPatch;
448
+ if (chatUI.patchReload) chatUI.patchReload.disabled = isRunning;
449
+ }
450
+
451
+ const docContent = getDocTextarea();
452
+ if (docContent) {
453
+ docContent.classList.toggle("hidden", hasPatch);
454
+ }
455
+
456
+ renderChatHistory(state);
457
+ }
458
+
459
+ function renderChatHistory(state) {
460
+ if (!chatUI.history) return;
461
+
462
+ const count = state.history.length;
463
+ chatUI.historyCount.textContent = count;
464
+
465
+ // Hide history details if empty
466
+ if (chatUI.historyDetails) {
467
+ chatUI.historyDetails.style.display = count === 0 ? "none" : "";
468
+ }
469
+
470
+ chatUI.history.innerHTML = "";
471
+ if (count === 0) return;
472
+
473
+ state.history.slice(0, CHAT_HISTORY_LIMIT).forEach((entry) => {
474
+ const wrapper = document.createElement("div");
475
+ wrapper.className = `doc-chat-entry ${entry.status}`;
476
+
477
+ // Prompt row with copy button
478
+ const promptRow = document.createElement("div");
479
+ promptRow.className = "prompt-row";
480
+
481
+ const prompt = document.createElement("div");
482
+ prompt.className = "prompt";
483
+ prompt.textContent = truncateText(entry.prompt, 60);
484
+ prompt.title = entry.prompt;
485
+
486
+ const copyBtn = document.createElement("button");
487
+ copyBtn.className = "copy-prompt-btn";
488
+ copyBtn.title = "Copy to input";
489
+ copyBtn.innerHTML = "↑";
490
+ copyBtn.addEventListener("click", (e) => {
491
+ e.stopPropagation();
492
+ chatUI.input.value = entry.prompt;
493
+ autoResizeTextarea(chatUI.input);
494
+ chatUI.input.focus();
495
+ historyNavIndex = -1;
496
+ flash("Prompt restored to input");
497
+ });
498
+
499
+ promptRow.appendChild(prompt);
500
+ promptRow.appendChild(copyBtn);
501
+
502
+ const response = document.createElement("div");
503
+ response.className = "response";
504
+ const preview = entry.error || entry.response || "(pending...)";
505
+ response.textContent = truncateText(preview, 80);
506
+ response.title = preview;
507
+
508
+ const detail = document.createElement("details");
509
+ detail.className = "doc-chat-entry-detail";
510
+ const summary = document.createElement("summary");
511
+ summary.textContent = "View details";
512
+ const body = document.createElement("div");
513
+ body.className = "doc-chat-entry-body";
514
+ if (entry.response) {
515
+ const respBlock = document.createElement("pre");
516
+ respBlock.textContent = entry.response;
517
+ body.appendChild(respBlock);
518
+ }
519
+ if (entry.patch) {
520
+ const patchBlock = document.createElement("pre");
521
+ patchBlock.className = "doc-chat-entry-patch";
522
+ patchBlock.textContent = entry.patch;
523
+ body.appendChild(patchBlock);
524
+ }
525
+ detail.appendChild(summary);
526
+ detail.appendChild(body);
527
+
528
+ const meta = document.createElement("div");
529
+ meta.className = "meta";
530
+
531
+ const dot = document.createElement("span");
532
+ dot.className = "status-dot";
533
+
534
+ const stamp = document.createElement("span");
535
+ stamp.textContent = entry.time
536
+ ? new Date(entry.time).toLocaleTimeString([], {
537
+ hour: "2-digit",
538
+ minute: "2-digit",
539
+ })
540
+ : entry.status;
541
+
542
+ meta.appendChild(dot);
543
+ meta.appendChild(stamp);
544
+
545
+ wrapper.appendChild(promptRow);
546
+ wrapper.appendChild(response);
547
+ wrapper.appendChild(detail);
548
+ wrapper.appendChild(meta);
549
+ chatUI.history.appendChild(wrapper);
550
+ });
551
+ }
552
+
553
+ // ─────────────────────────────────────────────────────────────────────────────
554
+ // Chat Actions & Error Handling
555
+ // ─────────────────────────────────────────────────────────────────────────────
556
+
557
+ function markChatError(state, entry, message) {
558
+ entry.status = "error";
559
+ entry.error = message;
560
+ state.error = message;
561
+ state.status = "error";
562
+ state.patch = "";
563
+ renderChat();
564
+ }
565
+
566
+ function cancelDocChat() {
567
+ const state = getChatState(activeDoc);
568
+ if (state.status !== "running") return;
569
+ if (state.controller) state.controller.abort();
570
+ const entry = state.history[0];
571
+ if (entry && entry.status === "running") {
572
+ entry.status = "error";
573
+ entry.error = "Cancelled";
574
+ }
575
+ state.status = "idle";
576
+ state.controller = null;
577
+ renderChat();
578
+ }
579
+
580
+ async function sendDocChat() {
581
+ const message = (chatUI.input.value || "").trim();
582
+ const state = getChatState(activeDoc);
583
+ if (!message) {
584
+ state.error = "Enter a message to send.";
585
+ renderChat();
586
+ return;
587
+ }
588
+ if (state.status === "running") return;
589
+
590
+ const entry = {
591
+ id: `${Date.now()}`,
592
+ prompt: message,
593
+ response: "",
594
+ status: "running",
595
+ time: Date.now(),
596
+ lastAppliedContent: null,
597
+ patch: "",
598
+ };
599
+ state.history.unshift(entry);
600
+ if (state.history.length > CHAT_HISTORY_LIMIT * 2) {
601
+ state.history.length = CHAT_HISTORY_LIMIT * 2;
602
+ }
603
+ state.status = "running";
604
+ state.error = "";
605
+ state.streamText = "";
606
+ state.patch = "";
607
+ state.statusText = "queued";
608
+ state.controller = new AbortController();
609
+
610
+ // Collapse history when starting new request for compact view
611
+ if (chatUI.historyDetails) {
612
+ chatUI.historyDetails.removeAttribute("open");
613
+ }
614
+
615
+ renderChat();
616
+ chatUI.input.value = "";
617
+ chatUI.input.style.height = "auto"; // Reset textarea height
618
+ chatUI.input.focus();
619
+
620
+ try {
621
+ await performDocChatRequest(activeDoc, entry, state);
622
+ if (entry.status !== "error") {
623
+ state.status = "idle";
624
+ state.error = "";
625
+ }
626
+ } catch (err) {
627
+ if (err.name === "AbortError") {
628
+ entry.status = "error";
629
+ entry.error = "Cancelled";
630
+ state.error = "";
631
+ state.status = "idle";
632
+ } else {
633
+ markChatError(state, entry, err.message || "Doc chat failed");
634
+ }
635
+ } finally {
636
+ state.controller = null;
637
+ if (state.status !== "running") {
638
+ renderChat();
639
+ }
640
+ }
641
+ }
642
+
643
+ // ─────────────────────────────────────────────────────────────────────────────
644
+ // Chat Networking & Streaming
645
+ // ─────────────────────────────────────────────────────────────────────────────
646
+
647
+ async function performDocChatRequest(kind, entry, state) {
648
+ const endpoint = resolvePath(`/api/docs/${kind}/chat`);
649
+ const headers = { "Content-Type": "application/json" };
650
+ const token = getAuthToken();
651
+ if (token) {
652
+ headers.Authorization = `Bearer ${token}`;
653
+ }
654
+ const res = await fetch(endpoint, {
655
+ method: "POST",
656
+ headers,
657
+ body: JSON.stringify({ message: entry.prompt, stream: true }),
658
+ signal: state.controller.signal,
659
+ });
660
+
661
+ if (!res.ok) {
662
+ const text = await res.text();
663
+ let detail = text;
664
+ try {
665
+ const parsed = JSON.parse(text);
666
+ detail = parsed.detail || parsed.error || text;
667
+ } catch (err) {
668
+ // ignore parse errors
669
+ }
670
+ throw new Error(detail || `Request failed (${res.status})`);
671
+ }
672
+
673
+ const contentType = res.headers.get("content-type") || "";
674
+ if (contentType.includes("text/event-stream")) {
675
+ await readChatStream(res, state, entry, kind);
676
+ if (entry.status !== "error" && entry.status !== "done") {
677
+ entry.status = "done";
678
+ }
679
+ } else {
680
+ const payload = contentType.includes("application/json")
681
+ ? await res.json()
682
+ : await res.text();
683
+ applyChatResult(payload, state, entry);
684
+ }
685
+ }
686
+
687
+ async function applyPatch(kind = activeDoc) {
688
+ const state = getChatState(kind);
689
+ if (!state.patch) {
690
+ flash("No patch to apply", "error");
691
+ return;
692
+ }
693
+ try {
694
+ const res = await api(`/api/docs/${kind}/chat/apply`, { method: "POST" });
695
+ const applied = parseChatPayload(res);
696
+ if (applied.error) throw new Error(applied.error);
697
+ if (applied.content) {
698
+ await applyDocUpdateFromChat(kind, applied.content);
699
+ }
700
+ state.patch = "";
701
+ const latest = state.history[0];
702
+ if (latest) latest.status = "done";
703
+ flash("Patch applied");
704
+ } catch (err) {
705
+ flash(err.message || "Failed to apply patch", "error");
706
+ } finally {
707
+ renderChat(kind);
708
+ }
709
+ }
710
+
711
+ async function discardPatch(kind = activeDoc) {
712
+ const state = getChatState(kind);
713
+ if (!state.patch) return;
714
+ try {
715
+ const res = await api(`/api/docs/${kind}/chat/discard`, { method: "POST" });
716
+ const parsed = parseChatPayload(res);
717
+ if (parsed.content) {
718
+ await applyDocUpdateFromChat(kind, parsed.content);
719
+ }
720
+ state.patch = "";
721
+ const latest = state.history[0];
722
+ if (latest && latest.status === "needs-apply") {
723
+ latest.status = "done";
724
+ }
725
+ flash("Discarded chat patch");
726
+ } catch (err) {
727
+ flash(err.message || "Failed to discard patch", "error");
728
+ } finally {
729
+ renderChat(kind);
730
+ }
731
+ }
732
+
733
+ async function reloadPatch(kind = activeDoc, silent = false) {
734
+ const state = getChatState(kind);
735
+ try {
736
+ const res = await api(`/api/docs/${kind}/chat/pending`, { method: "GET" });
737
+ const parsed = parseChatPayload(res);
738
+ if (parsed.error) throw new Error(parsed.error);
739
+ if (parsed.patch) {
740
+ state.patch = parsed.patch;
741
+ const entry = state.history[0] || {
742
+ id: `${Date.now()}`,
743
+ prompt: "(pending patch)",
744
+ response: parsed.response || "",
745
+ status: "needs-apply",
746
+ time: Date.now(),
747
+ lastAppliedContent: null,
748
+ patch: parsed.patch,
749
+ };
750
+ entry.patch = parsed.patch;
751
+ entry.response = parsed.response || entry.response;
752
+ entry.status = "needs-apply";
753
+ if (!state.history[0]) state.history.unshift(entry);
754
+ if (parsed.content) {
755
+ await applyDocUpdateFromChat(kind, parsed.content);
756
+ }
757
+ renderChat(kind);
758
+ if (!silent) flash("Loaded pending patch");
759
+ }
760
+ } catch (err) {
761
+ if (!silent) flash(err.message || "No pending patch", "error");
762
+ }
763
+ }
764
+
765
+ async function readChatStream(res, state, entry, kind) {
766
+ if (!res.body) throw new Error("Streaming not supported in this browser");
767
+ const reader = res.body.getReader();
768
+ let buffer = "";
769
+ for (;;) {
770
+ const { value, done } = await reader.read();
771
+ if (done) break;
772
+ buffer += chatDecoder.decode(value, { stream: true });
773
+ const chunks = buffer.split("\n\n");
774
+ buffer = chunks.pop();
775
+ for (const chunk of chunks) {
776
+ if (!chunk.trim()) continue;
777
+ let event = "message";
778
+ const dataLines = [];
779
+ chunk.split("\n").forEach((line) => {
780
+ if (line.startsWith("event:")) {
781
+ event = line.slice(6).trim();
782
+ } else if (line.startsWith("data:")) {
783
+ dataLines.push(line.slice(5).trimStart());
784
+ }
785
+ });
786
+ const data = dataLines.join("\n");
787
+ await handleStreamEvent(event || "message", data, state, entry, kind);
788
+ }
789
+ }
790
+ }
791
+
792
+ async function handleStreamEvent(event, rawData, state, entry, kind) {
793
+ const parsed = parseMaybeJson(rawData);
794
+ if (event === "status") {
795
+ state.statusText =
796
+ typeof parsed === "string" ? parsed : parsed.status || "";
797
+ renderChat(kind);
798
+ return;
799
+ }
800
+ if (event === "token") {
801
+ const token =
802
+ typeof parsed === "string"
803
+ ? parsed
804
+ : parsed.token || parsed.text || rawData || "";
805
+ entry.response = (entry.response || "") + token;
806
+ state.streamText = entry.response;
807
+ renderChat(kind);
808
+ return;
809
+ }
810
+ if (event === "update") {
811
+ const payload = parseChatPayload(parsed);
812
+ entry.response = payload.response || entry.response;
813
+ state.streamText = entry.response;
814
+ if (payload.patch) {
815
+ state.patch = payload.patch;
816
+ entry.patch = payload.patch;
817
+ entry.status = "needs-apply";
818
+ entry.response = payload.response || entry.response;
819
+ if (payload.content) {
820
+ await applyDocUpdateFromChat(kind, payload.content);
821
+ }
822
+ }
823
+ renderChat(kind);
824
+ return;
825
+ }
826
+ if (event === "error") {
827
+ const message =
828
+ (parsed && parsed.detail) ||
829
+ (parsed && parsed.error) ||
830
+ rawData ||
831
+ "Doc chat failed";
832
+ markChatError(state, entry, message);
833
+ throw new Error(message);
834
+ }
835
+ if (event === "done" || event === "finish") {
836
+ entry.status = "done";
837
+ return;
838
+ }
839
+ }
840
+
841
+ function applyChatResult(payload, state, entry) {
842
+ const parsed = parseChatPayload(payload);
843
+ if (parsed.error) {
844
+ markChatError(state, entry, parsed.error);
845
+ return;
846
+ }
847
+ entry.status = "done";
848
+ entry.response = parsed.response || "(no response)";
849
+ state.streamText = entry.response;
850
+ if (parsed.patch) {
851
+ state.patch = parsed.patch;
852
+ entry.patch = parsed.patch;
853
+ entry.status = "needs-apply";
854
+ entry.response = parsed.response || entry.response;
855
+ }
856
+ }
857
+
858
+ // ─────────────────────────────────────────────────────────────────────────────
859
+ // Doc CRUD Operations
860
+ // ─────────────────────────────────────────────────────────────────────────────
861
+
862
+ async function loadDocs() {
863
+ try {
864
+ const data = await api("/api/docs");
865
+ docsCache = { ...docsCache, ...data };
866
+ setDoc(activeDoc);
867
+ renderTodoPreview(docsCache.todo);
868
+ document.getElementById("doc-status").textContent = "Loaded";
869
+ publish("docs:loaded", docsCache);
870
+ } catch (err) {
871
+ flash(err.message);
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Safe auto-refresh for docs that skips if there are unsaved changes.
877
+ * This prevents overwriting user edits during background refresh.
878
+ */
879
+ async function safeLoadDocs() {
880
+ // Skip auto-refresh for snapshot (it has its own refresh mechanism)
881
+ if (activeDoc === "snapshot") {
882
+ return;
883
+ }
884
+ const textarea = getDocTextarea();
885
+ if (textarea) {
886
+ const currentValue = textarea.value;
887
+ const cachedValue = docsCache[activeDoc] || "";
888
+ // Skip refresh if there are unsaved local changes
889
+ if (currentValue !== cachedValue) {
890
+ return;
891
+ }
892
+ }
893
+ // Also skip if a chat operation is in progress
894
+ const state = getChatState(activeDoc);
895
+ if (state.status === "running") {
896
+ return;
897
+ }
898
+ try {
899
+ const data = await api("/api/docs");
900
+ // Check again after fetch - user might have started editing
901
+ if (textarea && textarea.value !== (docsCache[activeDoc] || "")) {
902
+ return;
903
+ }
904
+ docsCache = { ...docsCache, ...data };
905
+ setDoc(activeDoc);
906
+ renderTodoPreview(docsCache.todo);
907
+ publish("docs:loaded", docsCache);
908
+ } catch (err) {
909
+ // Silently fail for background refresh
910
+ console.error("Auto-refresh docs failed:", err);
911
+ }
912
+ }
913
+
914
+ function setDoc(kind) {
915
+ activeDoc = kind;
916
+ docButtons.forEach((btn) =>
917
+ btn.classList.toggle("active", btn.dataset.doc === kind)
918
+ );
919
+ const textarea = document.getElementById("doc-content");
920
+ const isSnapshot = kind === "snapshot";
921
+
922
+ // Handle snapshot vs regular doc display
923
+ if (isSnapshot) {
924
+ textarea.value = snapshotCache.content || "";
925
+ textarea.placeholder = "(snapshot will appear here)";
926
+ document.getElementById("doc-status").textContent = "Viewing SNAPSHOT";
927
+ } else {
928
+ textarea.value = docsCache[kind] || "";
929
+ textarea.placeholder = "";
930
+ document.getElementById("doc-status").textContent = `Editing ${kind.toUpperCase()}`;
931
+ }
932
+
933
+ // Toggle spec issue import UI
934
+ if (specIssueUI.row) {
935
+ specIssueUI.row.classList.toggle("hidden", kind !== "spec");
936
+ }
937
+
938
+ // Toggle action button sets - snapshot has its own, others share standard
939
+ if (docActionsUI.standard) {
940
+ docActionsUI.standard.classList.toggle("hidden", isSnapshot);
941
+ }
942
+ if (docActionsUI.snapshot) {
943
+ docActionsUI.snapshot.classList.toggle("hidden", !isSnapshot);
944
+ }
945
+
946
+ // Toggle document-specific buttons within standard actions
947
+ if (docActionsUI.ingest) {
948
+ docActionsUI.ingest.classList.toggle("hidden", kind !== "spec");
949
+ }
950
+ if (docActionsUI.clear) {
951
+ docActionsUI.clear.classList.toggle("hidden", !CLEARABLE_DOCS.includes(kind));
952
+ }
953
+ updateStandardActionButtons(kind);
954
+
955
+ // Toggle chat panel visibility - hide for snapshot
956
+ const chatPanel = document.querySelector(".doc-chat-panel");
957
+ if (chatPanel) {
958
+ chatPanel.classList.toggle("hidden", isSnapshot);
959
+ }
960
+
961
+ // Toggle patch panel visibility - hide for snapshot
962
+ if (chatUI.patchMain) {
963
+ if (isSnapshot) {
964
+ chatUI.patchMain.classList.add("hidden");
965
+ }
966
+ }
967
+
968
+ // Update snapshot button states when switching to snapshot
969
+ if (isSnapshot) {
970
+ renderSnapshotButtons();
971
+ } else {
972
+ reloadPatch(kind, true);
973
+ renderChat(kind);
974
+ }
975
+ updateUrlParams({ doc: kind });
976
+ }
977
+
978
+ async function importIssueToSpec() {
979
+ if (!specIssueUI.input || !specIssueUI.button) return;
980
+ const issue = (specIssueUI.input.value || "").trim();
981
+ if (!issue) {
982
+ flash("Enter a GitHub issue number or URL", "error");
983
+ return;
984
+ }
985
+ const state = getChatState("spec");
986
+ if (state.status === "running") {
987
+ flash("SPEC chat is running; try again shortly", "error");
988
+ return;
989
+ }
990
+
991
+ specIssueUI.button.disabled = true;
992
+ specIssueUI.button.classList.add("loading");
993
+ try {
994
+ const entry = {
995
+ id: `${Date.now()}`,
996
+ prompt: `Import issue → SPEC: ${issue}`,
997
+ response: "",
998
+ status: "running",
999
+ time: Date.now(),
1000
+ lastAppliedContent: null,
1001
+ patch: "",
1002
+ };
1003
+ state.history.unshift(entry);
1004
+ state.status = "running";
1005
+ state.error = "";
1006
+ state.streamText = "";
1007
+ state.patch = "";
1008
+ state.statusText = "importing issue";
1009
+ renderChat("spec");
1010
+
1011
+ const res = await api("/api/github/spec/from-issue", {
1012
+ method: "POST",
1013
+ body: { issue },
1014
+ });
1015
+ applyChatResult(res, state, entry);
1016
+ if (res?.content) {
1017
+ await applyDocUpdateFromChat("spec", res.content);
1018
+ }
1019
+ if (res?.patch) {
1020
+ state.patch = res.patch;
1021
+ entry.patch = res.patch;
1022
+ entry.status = "needs-apply";
1023
+ } else {
1024
+ entry.status = "done";
1025
+ }
1026
+ state.status = "idle";
1027
+ // Hide input row and reset toggle after successful import
1028
+ if (specIssueUI.inputRow) {
1029
+ specIssueUI.inputRow.classList.add("hidden");
1030
+ }
1031
+ if (specIssueUI.toggle) {
1032
+ specIssueUI.toggle.textContent = "Import Issue → SPEC";
1033
+ }
1034
+ if (specIssueUI.input) {
1035
+ specIssueUI.input.value = "";
1036
+ }
1037
+ flash("Imported issue into pending SPEC patch");
1038
+ } catch (err) {
1039
+ const message = err?.message || "Issue import failed";
1040
+ const entry = state.history[0];
1041
+ if (entry) {
1042
+ entry.status = "error";
1043
+ entry.error = message;
1044
+ }
1045
+ state.status = "idle";
1046
+ state.error = message;
1047
+ flash(message, "error");
1048
+ } finally {
1049
+ specIssueUI.button.disabled = false;
1050
+ specIssueUI.button.classList.remove("loading");
1051
+ renderChat("spec");
1052
+ }
1053
+ }
1054
+
1055
+ async function saveDoc() {
1056
+ // Snapshot is read-only, no saving
1057
+ if (activeDoc === "snapshot") {
1058
+ flash("Snapshot is read-only. Use Generate to update.", "error");
1059
+ return;
1060
+ }
1061
+ const content = document.getElementById("doc-content").value;
1062
+ const saveBtn = document.getElementById("save-doc");
1063
+ saveBtn.disabled = true;
1064
+ saveBtn.classList.add("loading");
1065
+ try {
1066
+ await api(`/api/docs/${activeDoc}`, { method: "PUT", body: { content } });
1067
+ docsCache[activeDoc] = content;
1068
+ flash(`${activeDoc.toUpperCase()} saved`);
1069
+ publish("docs:updated", { kind: activeDoc, content });
1070
+ if (activeDoc === "todo") {
1071
+ renderTodoPreview(content);
1072
+ await loadState({ notify: false });
1073
+ }
1074
+ } catch (err) {
1075
+ flash(err.message);
1076
+ } finally {
1077
+ saveBtn.disabled = false;
1078
+ saveBtn.classList.remove("loading");
1079
+ }
1080
+ }
1081
+
1082
+ // ─────────────────────────────────────────────────────────────────────────────
1083
+ // Snapshot Functions
1084
+ // ─────────────────────────────────────────────────────────────────────────────
1085
+
1086
+ function setSnapshotBusy(on) {
1087
+ snapshotBusy = on;
1088
+ const disabled = !!on;
1089
+ for (const btn of [snapshotUI.generate, snapshotUI.update, snapshotUI.regenerate, snapshotUI.refresh]) {
1090
+ if (btn) btn.disabled = disabled;
1091
+ }
1092
+ updateCopyButton(snapshotUI.copy, getDocCopyText("snapshot"), disabled);
1093
+ const statusEl = document.getElementById("doc-status");
1094
+ if (statusEl && activeDoc === "snapshot") {
1095
+ statusEl.textContent = on ? "Working…" : "Viewing SNAPSHOT";
1096
+ }
1097
+ }
1098
+
1099
+ function renderSnapshotButtons() {
1100
+ // Single default behavior: one "Run snapshot" action.
1101
+ if (snapshotUI.generate) snapshotUI.generate.classList.toggle("hidden", false);
1102
+ if (snapshotUI.update) snapshotUI.update.classList.toggle("hidden", true);
1103
+ if (snapshotUI.regenerate) snapshotUI.regenerate.classList.toggle("hidden", true);
1104
+ updateCopyButton(snapshotUI.copy, getDocCopyText("snapshot"), snapshotBusy);
1105
+ }
1106
+
1107
+ async function loadSnapshot({ notify = false } = {}) {
1108
+ if (snapshotBusy) return;
1109
+ try {
1110
+ setSnapshotBusy(true);
1111
+ const data = await api("/api/snapshot");
1112
+ snapshotCache = {
1113
+ exists: !!data?.exists,
1114
+ content: data?.content || "",
1115
+ state: data?.state || {},
1116
+ };
1117
+ if (activeDoc === "snapshot") {
1118
+ const textarea = getDocTextarea();
1119
+ if (textarea) textarea.value = snapshotCache.content || "";
1120
+ }
1121
+ renderSnapshotButtons();
1122
+ if (notify) flash(snapshotCache.exists ? "Snapshot loaded" : "No snapshot yet");
1123
+ } catch (err) {
1124
+ flash(err?.message || "Failed to load snapshot");
1125
+ } finally {
1126
+ setSnapshotBusy(false);
1127
+ }
1128
+ }
1129
+
1130
+ async function runSnapshot() {
1131
+ if (snapshotBusy) return;
1132
+ try {
1133
+ setSnapshotBusy(true);
1134
+ const data = await api("/api/snapshot", {
1135
+ method: "POST",
1136
+ body: {},
1137
+ });
1138
+ snapshotCache = {
1139
+ exists: true,
1140
+ content: data?.content || "",
1141
+ state: data?.state || {},
1142
+ };
1143
+ if (activeDoc === "snapshot") {
1144
+ const textarea = getDocTextarea();
1145
+ if (textarea) textarea.value = snapshotCache.content || "";
1146
+ }
1147
+ renderSnapshotButtons();
1148
+ flash("Snapshot generated");
1149
+ } catch (err) {
1150
+ flash(err?.message || "Snapshot generation failed");
1151
+ } finally {
1152
+ setSnapshotBusy(false);
1153
+ }
1154
+ }
1155
+
1156
+ // ─────────────────────────────────────────────────────────────────────────────
1157
+ // Initialization
1158
+ // ─────────────────────────────────────────────────────────────────────────────
1159
+
1160
+ function applyVoiceTranscript(text) {
1161
+ if (!text) {
1162
+ flash("Voice capture returned no transcript", "error");
1163
+ return;
1164
+ }
1165
+ const current = chatUI.input.value.trim();
1166
+ const prefix = current ? current + " " : "";
1167
+ let next = `${prefix}${text}`.trim();
1168
+ next = appendVoiceTranscriptDisclaimer(next);
1169
+ chatUI.input.value = next;
1170
+ autoResizeTextarea(chatUI.input);
1171
+ chatUI.input.focus();
1172
+ flash("Voice transcript added");
1173
+ }
1174
+
1175
+ function appendVoiceTranscriptDisclaimer(text) {
1176
+ const base = text === undefined || text === null ? "" : String(text);
1177
+ if (!base.trim()) return base;
1178
+ const injection = wrapInjectedContext(VOICE_TRANSCRIPT_DISCLAIMER_TEXT);
1179
+ if (base.includes(VOICE_TRANSCRIPT_DISCLAIMER_TEXT) || base.includes(injection)) {
1180
+ return base;
1181
+ }
1182
+ const separator = base.endsWith("\n") ? "\n" : "\n\n";
1183
+ return `${base}${separator}${injection}`;
1184
+ }
1185
+
1186
+ function wrapInjectedContext(text) {
1187
+ return `<injected context>\n${text}\n</injected context>`;
1188
+ }
1189
+
1190
+ function initDocVoice() {
1191
+ if (!chatUI.voiceBtn || !chatUI.input) {
1192
+ return;
1193
+ }
1194
+ initVoiceInput({
1195
+ button: chatUI.voiceBtn,
1196
+ input: chatUI.input,
1197
+ statusEl: chatUI.voiceStatus,
1198
+ onTranscript: applyVoiceTranscript,
1199
+ onError: (msg) => {
1200
+ if (msg) {
1201
+ flash(msg, "error");
1202
+ if (chatUI.voiceStatus) {
1203
+ chatUI.voiceStatus.textContent = msg;
1204
+ chatUI.voiceStatus.classList.remove("hidden");
1205
+ }
1206
+ }
1207
+ },
1208
+ }).catch((err) => {
1209
+ console.error("Voice init failed", err);
1210
+ flash("Voice capture unavailable", "error");
1211
+ });
1212
+ }
1213
+
1214
+ export function initDocs() {
1215
+ const urlDoc = getDocFromUrl();
1216
+ if (urlDoc) {
1217
+ activeDoc = urlDoc;
1218
+ }
1219
+ docButtons.forEach((btn) =>
1220
+ btn.addEventListener("click", () => {
1221
+ setDoc(btn.dataset.doc);
1222
+ })
1223
+ );
1224
+ document.getElementById("save-doc").addEventListener("click", saveDoc);
1225
+ document.getElementById("reload-doc").addEventListener("click", () => {
1226
+ if (activeDoc === "snapshot") {
1227
+ loadSnapshot({ notify: true });
1228
+ } else {
1229
+ loadDocs();
1230
+ }
1231
+ });
1232
+ document.getElementById("ingest-spec").addEventListener("click", ingestSpec);
1233
+ document.getElementById("clear-docs").addEventListener("click", clearDocs);
1234
+ if (docActionsUI.copy) {
1235
+ docActionsUI.copy.addEventListener("click", () =>
1236
+ copyDocToClipboard(activeDoc)
1237
+ );
1238
+ }
1239
+ if (docActionsUI.paste) {
1240
+ docActionsUI.paste.addEventListener("click", pasteSpecFromClipboard);
1241
+ }
1242
+ const docContent = getDocTextarea();
1243
+ if (docContent) {
1244
+ docContent.addEventListener("input", () => {
1245
+ if (activeDoc !== "snapshot") {
1246
+ updateStandardActionButtons(activeDoc);
1247
+ }
1248
+ });
1249
+ }
1250
+ let suppressNextSendClick = false;
1251
+ let lastSendTapAt = 0;
1252
+ const triggerSend = () => {
1253
+ const now = Date.now();
1254
+ if (now - lastSendTapAt < 300) return;
1255
+ lastSendTapAt = now;
1256
+ sendDocChat();
1257
+ };
1258
+ chatUI.send.addEventListener("pointerup", (e) => {
1259
+ if (e.pointerType !== "touch") return;
1260
+ if (e.cancelable) e.preventDefault();
1261
+ suppressNextSendClick = true;
1262
+ triggerSend();
1263
+ });
1264
+ chatUI.send.addEventListener("click", () => {
1265
+ if (suppressNextSendClick) {
1266
+ suppressNextSendClick = false;
1267
+ return;
1268
+ }
1269
+ triggerSend();
1270
+ });
1271
+ chatUI.cancel.addEventListener("click", cancelDocChat);
1272
+ if (chatUI.patchApply)
1273
+ chatUI.patchApply.addEventListener("click", () => applyPatch(activeDoc));
1274
+ if (chatUI.patchDiscard)
1275
+ chatUI.patchDiscard.addEventListener("click", () =>
1276
+ discardPatch(activeDoc)
1277
+ );
1278
+ if (chatUI.patchReload)
1279
+ chatUI.patchReload.addEventListener("click", () =>
1280
+ reloadPatch(activeDoc, true)
1281
+ );
1282
+ if (specIssueUI.toggle) {
1283
+ specIssueUI.toggle.addEventListener("click", () => {
1284
+ if (specIssueUI.inputRow) {
1285
+ const isHidden = specIssueUI.inputRow.classList.toggle("hidden");
1286
+ if (!isHidden && specIssueUI.input) {
1287
+ specIssueUI.input.focus();
1288
+ }
1289
+ // Update toggle button text
1290
+ specIssueUI.toggle.textContent = isHidden
1291
+ ? "Import Issue → SPEC"
1292
+ : "Cancel";
1293
+ }
1294
+ });
1295
+ }
1296
+ if (specIssueUI.button) {
1297
+ specIssueUI.button.addEventListener("click", () => {
1298
+ if (activeDoc !== "spec") setDoc("spec");
1299
+ importIssueToSpec();
1300
+ });
1301
+ }
1302
+ if (specIssueUI.input) {
1303
+ specIssueUI.input.addEventListener("keydown", (e) => {
1304
+ if (e.key === "Enter") {
1305
+ e.preventDefault();
1306
+ if (activeDoc !== "spec") setDoc("spec");
1307
+ importIssueToSpec();
1308
+ }
1309
+ });
1310
+ }
1311
+
1312
+ // Snapshot event handlers
1313
+ if (snapshotUI.generate) {
1314
+ snapshotUI.generate.addEventListener("click", () => runSnapshot());
1315
+ }
1316
+ if (snapshotUI.update) {
1317
+ snapshotUI.update.addEventListener("click", () => runSnapshot());
1318
+ }
1319
+ if (snapshotUI.regenerate) {
1320
+ snapshotUI.regenerate.addEventListener("click", () => runSnapshot());
1321
+ }
1322
+ if (snapshotUI.copy) {
1323
+ snapshotUI.copy.addEventListener("click", () =>
1324
+ copyDocToClipboard("snapshot")
1325
+ );
1326
+ }
1327
+ if (snapshotUI.refresh) {
1328
+ snapshotUI.refresh.addEventListener("click", () => loadSnapshot({ notify: true }));
1329
+ }
1330
+
1331
+ initDocVoice();
1332
+ reloadPatch(activeDoc, true);
1333
+
1334
+ // Cmd+Enter or Ctrl+Enter sends, Enter adds newline on all devices.
1335
+ // Up/Down arrows navigate prompt history when input is empty
1336
+ chatUI.input.addEventListener("keydown", (e) => {
1337
+ if (e.key === "Enter" && !e.isComposing) {
1338
+ const shouldSend = e.metaKey || e.ctrlKey;
1339
+ if (shouldSend) {
1340
+ e.preventDefault();
1341
+ sendDocChat();
1342
+ }
1343
+ e.stopPropagation();
1344
+ return;
1345
+ }
1346
+
1347
+ // Up arrow: recall previous prompts from history
1348
+ if (e.key === "ArrowUp") {
1349
+ const state = getChatState(activeDoc);
1350
+ const isEmpty = chatUI.input.value.trim() === "";
1351
+ const atStart = chatUI.input.selectionStart === 0;
1352
+ if ((isEmpty || atStart) && state.history.length > 0) {
1353
+ e.preventDefault();
1354
+ const maxIndex = state.history.length - 1;
1355
+ if (historyNavIndex < maxIndex) {
1356
+ historyNavIndex++;
1357
+ chatUI.input.value = state.history[historyNavIndex].prompt || "";
1358
+ autoResizeTextarea(chatUI.input);
1359
+ // Move cursor to end
1360
+ chatUI.input.setSelectionRange(
1361
+ chatUI.input.value.length,
1362
+ chatUI.input.value.length
1363
+ );
1364
+ }
1365
+ }
1366
+ return;
1367
+ }
1368
+
1369
+ // Down arrow: navigate forward in history or clear
1370
+ if (e.key === "ArrowDown") {
1371
+ const state = getChatState(activeDoc);
1372
+ const atEnd = chatUI.input.selectionStart === chatUI.input.value.length;
1373
+ if (historyNavIndex >= 0 && atEnd) {
1374
+ e.preventDefault();
1375
+ historyNavIndex--;
1376
+ if (historyNavIndex >= 0) {
1377
+ chatUI.input.value = state.history[historyNavIndex].prompt || "";
1378
+ } else {
1379
+ chatUI.input.value = "";
1380
+ }
1381
+ autoResizeTextarea(chatUI.input);
1382
+ chatUI.input.setSelectionRange(
1383
+ chatUI.input.value.length,
1384
+ chatUI.input.value.length
1385
+ );
1386
+ }
1387
+ return;
1388
+ }
1389
+ });
1390
+
1391
+ // Clear errors on input, auto-resize textarea, and reset history navigation
1392
+ chatUI.input.addEventListener("input", () => {
1393
+ const state = getChatState(activeDoc);
1394
+ if (state.error) {
1395
+ state.error = "";
1396
+ renderChat();
1397
+ }
1398
+ // Reset history navigation when user types
1399
+ historyNavIndex = -1;
1400
+ autoResizeTextarea(chatUI.input);
1401
+ });
1402
+
1403
+ // Ctrl+S / Cmd+S saves the current doc
1404
+ document.addEventListener("keydown", (e) => {
1405
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
1406
+ // Only handle if docs tab is active
1407
+ const docsTab = document.getElementById("docs");
1408
+ if (docsTab && !docsTab.classList.contains("hidden")) {
1409
+ e.preventDefault();
1410
+ saveDoc();
1411
+ }
1412
+ }
1413
+ });
1414
+
1415
+ loadDocs();
1416
+ loadSnapshot().catch(() => {}); // Pre-load snapshot data
1417
+ renderChat(activeDoc);
1418
+ document.body.dataset.docsReady = "true";
1419
+ publish("docs:ready");
1420
+
1421
+ // Register auto-refresh for docs (only when docs tab is active)
1422
+ // Uses a smart refresh that checks for unsaved changes
1423
+ registerAutoRefresh("docs-content", {
1424
+ callback: safeLoadDocs,
1425
+ tabId: "docs",
1426
+ interval: CONSTANTS.UI.AUTO_REFRESH_INTERVAL,
1427
+ refreshOnActivation: true,
1428
+ immediate: false, // Already called loadDocs() above
1429
+ });
1430
+ }
1431
+
1432
+ async function ingestSpec() {
1433
+ const needsForce = ["todo", "progress", "opinions"].some(
1434
+ (k) => (docsCache[k] || "").trim().length > 0
1435
+ );
1436
+ if (needsForce) {
1437
+ const ok = await confirmModal(
1438
+ "Overwrite TODO, PROGRESS, and OPINIONS from SPEC? Existing content will be replaced."
1439
+ );
1440
+ if (!ok) return;
1441
+ }
1442
+ const button = document.getElementById("ingest-spec");
1443
+ button.disabled = true;
1444
+ button.classList.add("loading");
1445
+ try {
1446
+ const data = await api("/api/ingest-spec", {
1447
+ method: "POST",
1448
+ body: { force: needsForce },
1449
+ });
1450
+ docsCache = { ...docsCache, ...data };
1451
+ setDoc(activeDoc);
1452
+ renderTodoPreview(docsCache.todo);
1453
+ publish("docs:updated", { kind: "todo", content: docsCache.todo });
1454
+ publish("docs:updated", { kind: "progress", content: docsCache.progress });
1455
+ publish("docs:updated", { kind: "opinions", content: docsCache.opinions });
1456
+ await loadState({ notify: false });
1457
+ flash("Ingested SPEC into docs");
1458
+ } catch (err) {
1459
+ flash(err.message, "error");
1460
+ } finally {
1461
+ button.disabled = false;
1462
+ button.classList.remove("loading");
1463
+ }
1464
+ }
1465
+
1466
+ // ─────────────────────────────────────────────────────────────────────────────
1467
+ // Spec Ingestion & Doc Clearing
1468
+ // ─────────────────────────────────────────────────────────────────────────────
1469
+
1470
+ async function clearDocs() {
1471
+ const confirmed = await confirmModal(
1472
+ "Clear TODO, PROGRESS, and OPINIONS? This action cannot be undone."
1473
+ );
1474
+ if (!confirmed) {
1475
+ flash("Clear cancelled");
1476
+ return;
1477
+ }
1478
+ const button = document.getElementById("clear-docs");
1479
+ button.disabled = true;
1480
+ button.classList.add("loading");
1481
+ try {
1482
+ const data = await api("/api/docs/clear", { method: "POST" });
1483
+ docsCache = { ...docsCache, ...data };
1484
+ // Update UI directly (consistent with ingestSpec)
1485
+ setDoc(activeDoc);
1486
+ renderTodoPreview(docsCache.todo);
1487
+ publish("docs:updated", { kind: "todo", content: docsCache.todo });
1488
+ publish("docs:updated", { kind: "progress", content: docsCache.progress });
1489
+ publish("docs:updated", { kind: "opinions", content: docsCache.opinions });
1490
+ flash("Cleared TODO/PROGRESS/OPINIONS");
1491
+ } catch (err) {
1492
+ flash(err.message, "error");
1493
+ } finally {
1494
+ button.disabled = false;
1495
+ button.classList.remove("loading");
1496
+ }
1497
+ }
1498
+
1499
+ // ─────────────────────────────────────────────────────────────────────────────
1500
+ // Test Exports
1501
+ // ─────────────────────────────────────────────────────────────────────────────
1502
+
1503
+ export const __docChatTest = {
1504
+ applyChatResult,
1505
+ applyDocUpdateFromChat,
1506
+ applyPatch,
1507
+ reloadPatch,
1508
+ discardPatch,
1509
+ getChatState,
1510
+ handleStreamEvent,
1511
+ performDocChatRequest,
1512
+ renderChat,
1513
+ setDoc,
1514
+ };