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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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, "<")
|
|
177
|
+
.replace(/>/g, ">");
|
|
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
|
+
};
|