codex-partner 0.0.1__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 (244) hide show
  1. app.py +5530 -0
  2. codex_partner/__init__.py +4 -0
  3. codex_partner/__main__.py +6 -0
  4. codex_partner/app_server.py +150 -0
  5. codex_partner/cli.py +35 -0
  6. codex_partner/commands.py +77 -0
  7. codex_partner/database.py +206 -0
  8. codex_partner/discovery.py +136 -0
  9. codex_partner/schemas.py +133 -0
  10. codex_partner-0.0.1.dist-info/METADATA +101 -0
  11. codex_partner-0.0.1.dist-info/RECORD +244 -0
  12. codex_partner-0.0.1.dist-info/WHEEL +5 -0
  13. codex_partner-0.0.1.dist-info/entry_points.txt +2 -0
  14. codex_partner-0.0.1.dist-info/top_level.txt +3 -0
  15. static/__init__.py +1 -0
  16. static/app.js +505 -0
  17. static/chat-worker.js +157 -0
  18. static/conversation.js +894 -0
  19. static/core.js +880 -0
  20. static/editor.js +142 -0
  21. static/index.html +71 -0
  22. static/mascot-dance.css +100 -0
  23. static/mascot-dance.js +46 -0
  24. static/settings.js +335 -0
  25. static/styles.css +690 -0
  26. static/vendor/ace/LICENSE +24 -0
  27. static/vendor/ace/ace.js +23 -0
  28. static/vendor/ace/ext-modelist.js +8 -0
  29. static/vendor/ace/mode-abap.js +8 -0
  30. static/vendor/ace/mode-abc.js +8 -0
  31. static/vendor/ace/mode-actionscript.js +8 -0
  32. static/vendor/ace/mode-ada.js +8 -0
  33. static/vendor/ace/mode-alda.js +8 -0
  34. static/vendor/ace/mode-apache_conf.js +8 -0
  35. static/vendor/ace/mode-apex.js +8 -0
  36. static/vendor/ace/mode-applescript.js +8 -0
  37. static/vendor/ace/mode-aql.js +8 -0
  38. static/vendor/ace/mode-asciidoc.js +8 -0
  39. static/vendor/ace/mode-asl.js +8 -0
  40. static/vendor/ace/mode-assembly_arm32.js +8 -0
  41. static/vendor/ace/mode-assembly_x86.js +8 -0
  42. static/vendor/ace/mode-astro.js +8 -0
  43. static/vendor/ace/mode-autohotkey.js +8 -0
  44. static/vendor/ace/mode-basic.js +8 -0
  45. static/vendor/ace/mode-batchfile.js +8 -0
  46. static/vendor/ace/mode-bibtex.js +8 -0
  47. static/vendor/ace/mode-c9search.js +8 -0
  48. static/vendor/ace/mode-c_cpp.js +8 -0
  49. static/vendor/ace/mode-cedar.js +8 -0
  50. static/vendor/ace/mode-cedarschema.js +8 -0
  51. static/vendor/ace/mode-cirru.js +8 -0
  52. static/vendor/ace/mode-clojure.js +8 -0
  53. static/vendor/ace/mode-clue.js +8 -0
  54. static/vendor/ace/mode-cobol.js +8 -0
  55. static/vendor/ace/mode-coffee.js +8 -0
  56. static/vendor/ace/mode-coldfusion.js +8 -0
  57. static/vendor/ace/mode-crystal.js +8 -0
  58. static/vendor/ace/mode-csharp.js +8 -0
  59. static/vendor/ace/mode-csound_document.js +8 -0
  60. static/vendor/ace/mode-csound_orchestra.js +8 -0
  61. static/vendor/ace/mode-csound_score.js +8 -0
  62. static/vendor/ace/mode-csp.js +8 -0
  63. static/vendor/ace/mode-css.js +8 -0
  64. static/vendor/ace/mode-csv.js +8 -0
  65. static/vendor/ace/mode-curly.js +8 -0
  66. static/vendor/ace/mode-cuttlefish.js +8 -0
  67. static/vendor/ace/mode-d.js +8 -0
  68. static/vendor/ace/mode-dart.js +8 -0
  69. static/vendor/ace/mode-diff.js +8 -0
  70. static/vendor/ace/mode-django.js +8 -0
  71. static/vendor/ace/mode-dockerfile.js +8 -0
  72. static/vendor/ace/mode-dot.js +8 -0
  73. static/vendor/ace/mode-drools.js +8 -0
  74. static/vendor/ace/mode-edifact.js +8 -0
  75. static/vendor/ace/mode-eiffel.js +8 -0
  76. static/vendor/ace/mode-ejs.js +8 -0
  77. static/vendor/ace/mode-elixir.js +8 -0
  78. static/vendor/ace/mode-elm.js +8 -0
  79. static/vendor/ace/mode-erlang.js +8 -0
  80. static/vendor/ace/mode-flix.js +8 -0
  81. static/vendor/ace/mode-forth.js +8 -0
  82. static/vendor/ace/mode-fortran.js +8 -0
  83. static/vendor/ace/mode-fsharp.js +8 -0
  84. static/vendor/ace/mode-fsl.js +8 -0
  85. static/vendor/ace/mode-ftl.js +8 -0
  86. static/vendor/ace/mode-gcode.js +8 -0
  87. static/vendor/ace/mode-gherkin.js +8 -0
  88. static/vendor/ace/mode-gitignore.js +8 -0
  89. static/vendor/ace/mode-glsl.js +8 -0
  90. static/vendor/ace/mode-gobstones.js +8 -0
  91. static/vendor/ace/mode-golang.js +8 -0
  92. static/vendor/ace/mode-graphqlschema.js +8 -0
  93. static/vendor/ace/mode-groovy.js +8 -0
  94. static/vendor/ace/mode-groq.js +8 -0
  95. static/vendor/ace/mode-haml.js +8 -0
  96. static/vendor/ace/mode-handlebars.js +8 -0
  97. static/vendor/ace/mode-haskell.js +8 -0
  98. static/vendor/ace/mode-haskell_cabal.js +8 -0
  99. static/vendor/ace/mode-haxe.js +8 -0
  100. static/vendor/ace/mode-hjson.js +8 -0
  101. static/vendor/ace/mode-html.js +8 -0
  102. static/vendor/ace/mode-html_elixir.js +8 -0
  103. static/vendor/ace/mode-html_ruby.js +8 -0
  104. static/vendor/ace/mode-ini.js +8 -0
  105. static/vendor/ace/mode-io.js +8 -0
  106. static/vendor/ace/mode-ion.js +8 -0
  107. static/vendor/ace/mode-jack.js +8 -0
  108. static/vendor/ace/mode-jade.js +8 -0
  109. static/vendor/ace/mode-java.js +8 -0
  110. static/vendor/ace/mode-javascript.js +8 -0
  111. static/vendor/ace/mode-jexl.js +8 -0
  112. static/vendor/ace/mode-json.js +8 -0
  113. static/vendor/ace/mode-json5.js +8 -0
  114. static/vendor/ace/mode-jsoniq.js +8 -0
  115. static/vendor/ace/mode-jsp.js +8 -0
  116. static/vendor/ace/mode-jssm.js +8 -0
  117. static/vendor/ace/mode-jsx.js +8 -0
  118. static/vendor/ace/mode-julia.js +8 -0
  119. static/vendor/ace/mode-kotlin.js +8 -0
  120. static/vendor/ace/mode-latex.js +8 -0
  121. static/vendor/ace/mode-latte.js +8 -0
  122. static/vendor/ace/mode-less.js +8 -0
  123. static/vendor/ace/mode-liquid.js +8 -0
  124. static/vendor/ace/mode-lisp.js +8 -0
  125. static/vendor/ace/mode-livescript.js +8 -0
  126. static/vendor/ace/mode-logiql.js +8 -0
  127. static/vendor/ace/mode-logtalk.js +8 -0
  128. static/vendor/ace/mode-lsl.js +8 -0
  129. static/vendor/ace/mode-lua.js +8 -0
  130. static/vendor/ace/mode-luapage.js +8 -0
  131. static/vendor/ace/mode-lucene.js +8 -0
  132. static/vendor/ace/mode-makefile.js +8 -0
  133. static/vendor/ace/mode-mariadb.js +8 -0
  134. static/vendor/ace/mode-markdown.js +8 -0
  135. static/vendor/ace/mode-mask.js +8 -0
  136. static/vendor/ace/mode-matlab.js +8 -0
  137. static/vendor/ace/mode-maze.js +8 -0
  138. static/vendor/ace/mode-mediawiki.js +8 -0
  139. static/vendor/ace/mode-mel.js +8 -0
  140. static/vendor/ace/mode-mips.js +8 -0
  141. static/vendor/ace/mode-mixal.js +8 -0
  142. static/vendor/ace/mode-mushcode.js +8 -0
  143. static/vendor/ace/mode-mysql.js +8 -0
  144. static/vendor/ace/mode-nasal.js +8 -0
  145. static/vendor/ace/mode-nginx.js +8 -0
  146. static/vendor/ace/mode-nim.js +8 -0
  147. static/vendor/ace/mode-nix.js +8 -0
  148. static/vendor/ace/mode-nsis.js +8 -0
  149. static/vendor/ace/mode-nunjucks.js +8 -0
  150. static/vendor/ace/mode-objectivec.js +8 -0
  151. static/vendor/ace/mode-ocaml.js +8 -0
  152. static/vendor/ace/mode-odin.js +8 -0
  153. static/vendor/ace/mode-partiql.js +8 -0
  154. static/vendor/ace/mode-pascal.js +8 -0
  155. static/vendor/ace/mode-perl.js +8 -0
  156. static/vendor/ace/mode-pgsql.js +8 -0
  157. static/vendor/ace/mode-php.js +8 -0
  158. static/vendor/ace/mode-php_laravel_blade.js +8 -0
  159. static/vendor/ace/mode-pig.js +8 -0
  160. static/vendor/ace/mode-plain_text.js +8 -0
  161. static/vendor/ace/mode-plsql.js +8 -0
  162. static/vendor/ace/mode-powershell.js +8 -0
  163. static/vendor/ace/mode-praat.js +8 -0
  164. static/vendor/ace/mode-prisma.js +8 -0
  165. static/vendor/ace/mode-prolog.js +8 -0
  166. static/vendor/ace/mode-properties.js +8 -0
  167. static/vendor/ace/mode-protobuf.js +8 -0
  168. static/vendor/ace/mode-prql.js +8 -0
  169. static/vendor/ace/mode-puppet.js +8 -0
  170. static/vendor/ace/mode-python.js +8 -0
  171. static/vendor/ace/mode-qml.js +8 -0
  172. static/vendor/ace/mode-r.js +8 -0
  173. static/vendor/ace/mode-raku.js +8 -0
  174. static/vendor/ace/mode-razor.js +8 -0
  175. static/vendor/ace/mode-rdoc.js +8 -0
  176. static/vendor/ace/mode-red.js +8 -0
  177. static/vendor/ace/mode-redshift.js +8 -0
  178. static/vendor/ace/mode-rhtml.js +8 -0
  179. static/vendor/ace/mode-robot.js +8 -0
  180. static/vendor/ace/mode-rst.js +8 -0
  181. static/vendor/ace/mode-ruby.js +8 -0
  182. static/vendor/ace/mode-rust.js +8 -0
  183. static/vendor/ace/mode-sac.js +8 -0
  184. static/vendor/ace/mode-sass.js +8 -0
  185. static/vendor/ace/mode-scad.js +8 -0
  186. static/vendor/ace/mode-scala.js +8 -0
  187. static/vendor/ace/mode-scheme.js +8 -0
  188. static/vendor/ace/mode-scrypt.js +8 -0
  189. static/vendor/ace/mode-scss.js +8 -0
  190. static/vendor/ace/mode-sh.js +8 -0
  191. static/vendor/ace/mode-sjs.js +8 -0
  192. static/vendor/ace/mode-slim.js +8 -0
  193. static/vendor/ace/mode-smarty.js +8 -0
  194. static/vendor/ace/mode-smithy.js +8 -0
  195. static/vendor/ace/mode-snippets.js +8 -0
  196. static/vendor/ace/mode-soy_template.js +8 -0
  197. static/vendor/ace/mode-space.js +8 -0
  198. static/vendor/ace/mode-sparql.js +8 -0
  199. static/vendor/ace/mode-sql.js +8 -0
  200. static/vendor/ace/mode-sqlserver.js +8 -0
  201. static/vendor/ace/mode-stylus.js +8 -0
  202. static/vendor/ace/mode-svg.js +8 -0
  203. static/vendor/ace/mode-swift.js +8 -0
  204. static/vendor/ace/mode-tcl.js +8 -0
  205. static/vendor/ace/mode-terraform.js +8 -0
  206. static/vendor/ace/mode-tex.js +8 -0
  207. static/vendor/ace/mode-text.js +8 -0
  208. static/vendor/ace/mode-textile.js +8 -0
  209. static/vendor/ace/mode-toml.js +8 -0
  210. static/vendor/ace/mode-tsv.js +8 -0
  211. static/vendor/ace/mode-tsx.js +8 -0
  212. static/vendor/ace/mode-turtle.js +8 -0
  213. static/vendor/ace/mode-twig.js +8 -0
  214. static/vendor/ace/mode-typescript.js +8 -0
  215. static/vendor/ace/mode-vala.js +8 -0
  216. static/vendor/ace/mode-vbscript.js +8 -0
  217. static/vendor/ace/mode-velocity.js +8 -0
  218. static/vendor/ace/mode-verilog.js +8 -0
  219. static/vendor/ace/mode-vhdl.js +8 -0
  220. static/vendor/ace/mode-visualforce.js +8 -0
  221. static/vendor/ace/mode-vue.js +8 -0
  222. static/vendor/ace/mode-wollok.js +8 -0
  223. static/vendor/ace/mode-xml.js +8 -0
  224. static/vendor/ace/mode-xquery.js +8 -0
  225. static/vendor/ace/mode-yaml.js +8 -0
  226. static/vendor/ace/mode-zeek.js +8 -0
  227. static/vendor/ace/mode-zig.js +8 -0
  228. static/vendor/ace/theme-tomorrow_night_eighties.js +8 -0
  229. static/vendor/i18next/LICENSE +21 -0
  230. static/vendor/i18next/i18next.min.js +1 -0
  231. static/vendor/toastui-editor/LICENSE +21 -0
  232. static/vendor/toastui-editor/toastui-editor-dark.css +474 -0
  233. static/vendor/toastui-editor/toastui-editor.css +1597 -0
  234. static/vendor/toastui-editor/toastui-editor.js +24 -0
  235. static/vendor/toastui-editor/zh-cn.js +7 -0
  236. static/vendor/xterm/LICENSE +21 -0
  237. static/vendor/xterm/addons/fit.LICENSE +19 -0
  238. static/vendor/xterm/addons/fit.js +2 -0
  239. static/vendor/xterm/addons/unicode11.LICENSE +19 -0
  240. static/vendor/xterm/addons/unicode11.js +2 -0
  241. static/vendor/xterm/addons/web-links.LICENSE +19 -0
  242. static/vendor/xterm/addons/web-links.js +2 -0
  243. static/vendor/xterm/xterm.css +285 -0
  244. static/vendor/xterm/xterm.js +2 -0
static/conversation.js ADDED
@@ -0,0 +1,894 @@
1
+ // Session list, conversation rendering, workspace browser, and live terminal.
2
+ const chatFileObjectUrls = new Map();
3
+ const chatThumbnailObjectUrls = new Map();
4
+ const chatThumbnailLoads = new Map();
5
+ const chatWorker = typeof Worker === "function" ? new Worker("/chat-worker.js?v=20260816-history-pages") : null;
6
+ let chatBuildRequestId = 0;
7
+ let chatHistoryLoadPromise = null;
8
+ function renderSidebarStats() {
9
+ const running = sortTasks(state.tasks.filter(task => ["running", "retrying"].includes(task.status)));
10
+ const queued = sortTasks(state.tasks.filter(task => task.status === "queued"));
11
+ const live = [...running, ...queued];
12
+ $("#sidebar-stats").textContent = `${state.tasks.length} ${t("sessions")} · ${running.length} ${t("statusRunning")}`;
13
+ const summary = $("#running-summary");
14
+ if (!summary) return;
15
+ summary.hidden = !live.length;
16
+ if (!live.length) { summary.innerHTML = ""; return; }
17
+ const title = running.length ? `${t("statusRunning")} ${running.length} ${t("sessions")}${queued.length ? ` · ${t("statusQueued")} ${queued.length}` : ""}` : `${t("statusQueued")} ${queued.length} ${t("sessions")}`;
18
+ summary.innerHTML = `<div class="running-summary-head"><span class="live-dot"></span><strong>${title}</strong></div><div class="running-summary-list">${live.slice(0, 4).map(task => `<button data-session-id="${esc(task.id)}"><span>${esc(task.name || "Codex 会话")}</span><small>${esc(statusLabel(task.status))}${task.provider_id ? ` · ${esc(state.providers.find(provider => provider.id === task.provider_id)?.name || task.provider_id)}` : ""}</small></button>`).join("")}</div>${live.length > 4 ? `<small class="running-summary-more">还有 ${live.length - 4} 个活动会话</small>` : ""}`;
19
+ }
20
+ function renderSessionList() {
21
+ const list = $("#task-list"); const tasks = sortTasks(state.tasks.filter(taskMatches));
22
+ if (!tasks.length) { list.innerHTML = `<div class="empty">${state.query ? "没有匹配的会话" : "还没有 Codex 会话"}</div>`; return; }
23
+ list.innerHTML = tasks.map(task => { const active = ["running", "retrying"].includes(task.status); const name = task.name || t("sessions"); return `<button class="session-card ${task.id === state.selectedId ? "selected" : ""} ${active ? "running" : "paused"}" data-session-id="${esc(task.id)}" title="${esc(name)}"><span class="session-card-body"><strong title="${esc(name)}">${esc(name)}</strong><time class="session-card-time">${esc(shortDate(task.updated_at))}</time></span></button>`; }).join("");
24
+ }
25
+ function scrollSessionIntoView(id) {
26
+ const card = $$(".session-card").find(node => node.dataset.sessionId === id);
27
+ card?.scrollIntoView({ block: "nearest" });
28
+ }
29
+ function stopQueueSync() {
30
+ clearInterval(queueSyncTimer);
31
+ queueSyncTimer = null;
32
+ queueSyncInFlight = false;
33
+ }
34
+ async function syncQueuedMessages() {
35
+ if (queueSyncInFlight || !state.selectedId) return;
36
+ const taskId = state.selectedId;
37
+ queueSyncInFlight = true;
38
+ try {
39
+ const messages = await api(`/tasks/${taskId}/messages`);
40
+ if (state.selectedId !== taskId) return;
41
+ replaceTaskMessages(messages);
42
+ renderQueuedMessages();
43
+ scheduleRenderChat();
44
+ } catch (_) {
45
+ // The websocket remains the primary channel; the next interval retries.
46
+ } finally {
47
+ queueSyncInFlight = false;
48
+ }
49
+ }
50
+ function showEmptyConversation() { if (terminalTaskId) destroyTerminal(); stopQueueSync(); state.editingQueuedId = null; state.inspectorClosed = true; $("#empty-conversation").hidden = false; $("#conversation-view").hidden = true; $("#goal-bar").hidden = true; $("#queued-messages").hidden = true; setInspectorOpen(false); }
51
+ function resetWorkspaceBrowser() {
52
+ state.workspacePath = "";
53
+ state.workspaceFile = null;
54
+ state.workspaceRequestId += 1;
55
+ $("#workspace-section")?.remove();
56
+ $(".workspace-error")?.remove();
57
+ }
58
+ async function selectSession(id, openSocket = true) {
59
+ const requestId = ++state.sessionRequestId;
60
+ if (terminalTaskId && terminalTaskId !== id) closeTerminal();
61
+ const wasEmpty = $("#conversation-view").hidden;
62
+ if (state.selectedId !== id) {
63
+ resetWorkspaceBrowser(); state.titleExpanded = false; state.historyCursor = ""; state.historyHasMore = false;
64
+ state.historyLoading = false; state.chatBlocks = []; state.chatVirtualStart = null;
65
+ }
66
+ state.selectedId = id; localStorage.setItem("codex-dashboard-session", id);
67
+ const task = state.tasks.find(item => item.id === id);
68
+ if (task) state.selectedTask = task;
69
+ // Reflect keyboard navigation immediately, then replace the row with the full task after loading.
70
+ renderSessionList();
71
+ scrollSessionIntoView(id);
72
+ const [fullTask, timeline, messages] = await Promise.all([api(`/tasks/${id}`), api(`/tasks/${id}/timeline?limit=160`), api(`/tasks/${id}/messages`)]);
73
+ if (requestId !== state.sessionRequestId || state.selectedId !== id) return;
74
+ state.selectedTask = fullTask;
75
+ state.selectedEvents = [...(timeline.items || []), ...(timeline.metrics || [])];
76
+ state.historyCursor = timeline.next_cursor || ""; state.historyHasMore = Boolean(timeline.has_more);
77
+ replaceTaskMessages(messages);
78
+ // A newly selected thread should open at its latest message. Consume this
79
+ // once in renderChat instead of smooth-scrolling on every intermediate
80
+ // update while the history/socket is loading.
81
+ state.chatSnapToBottom = true;
82
+ $("#empty-conversation").hidden = true; $("#conversation-view").hidden = false;
83
+ if (wasEmpty && window.innerWidth >= 861) state.inspectorClosed = false;
84
+ setInspectorOpen(window.innerWidth >= 861 && !state.inspectorClosed);
85
+ renderSessionList(); renderConversation(); await loadWorkspace("");
86
+ if (openSocket) connectSocket(id); if (window.innerWidth < 900) $(".session-sidebar").classList.remove("open");
87
+ }
88
+ function renderConversation() {
89
+ const task = state.selectedTask; if (!task) return;
90
+ const title = String(task.name || t("sessions"));
91
+ const titleNode = $("#conversation-name");
92
+ const titleToggle = $("#conversation-title-toggle");
93
+ titleNode.textContent = title;
94
+ const longTitle = title.length > 36;
95
+ titleNode.title = title;
96
+ titleToggle.hidden = true;
97
+ titleNode.classList.toggle("expanded", false);
98
+ titleToggle.onclick = () => { state.titleExpanded = !state.titleExpanded; renderConversation(); };
99
+ const threadId = task.codex_session_id || task.id || "";
100
+ const workspace = task.workspace || "";
101
+ const location = task.ssh_host ? `${task.ssh_host} · ` : "";
102
+ $("#conversation-subtitle").textContent = `${location}${workspace} · ${uiLabel("threadId")} ${threadId || "—"}`;
103
+ $("#composer-goal-meta").hidden = !task.goal;
104
+ $("#composer-goal-meta").textContent = task.goal ? `${uiLabel("goal")} · ${goalStatusLabel(task.goal_status || "active")}` : "";
105
+ const composerSettings = composerModelCache.get(`${task.id}:${task.provider_id || "default"}`) || {};
106
+ renderComposerModelSelect(task, Array.isArray(composerSettings) ? composerSettings : composerSettings.models || []);
107
+ renderComposerEffortSelect(task, Array.isArray(composerSettings) ? "" : composerSettings.reasoning_effort || task.reasoning_effort || "");
108
+ loadComposerModels(task);
109
+ $("#permission-toggle").classList.toggle("active", task.yolo);
110
+ $("#permission-toggle span").textContent = task.yolo ? "YOLO" : "受控";
111
+ state.runtimeMetrics = calculateRuntimeMetrics(task, state.selectedEvents);
112
+ renderConnectionStatus();
113
+ renderContextUsage();
114
+ renderTurnProgress();
115
+ renderGoalBar();
116
+ refreshComposerHistory();
117
+ renderQueuedMessages();
118
+ renderChat(); renderInspector();
119
+ }
120
+ const composerModelCache = new Map();
121
+ const composerModelLoads = new Map();
122
+ function normalizeModelItems(raw) {
123
+ const items = (Array.isArray(raw) ? raw : []).map(item => typeof item === "string" ? { id: item, label: item } : { id: item?.id || item?.model || item?.slug || "", label: item?.displayName || item?.display_name || item?.label || item?.name || item?.id || item?.model || item?.slug || "" }).filter(item => item.id);
124
+ return [...new Map(items.map(item => [item.id, item])).values()];
125
+ }
126
+ function renderComposerModelSelect(task, models = []) {
127
+ const select = $("#composer-model-select");
128
+ if (!select) return;
129
+ const current = task.model || "";
130
+ const unique = normalizeModelItems(models);
131
+ if (current && !unique.some(item => item.id === current)) unique.unshift({ id: current, label: `${current}(当前)` });
132
+ select.innerHTML = `<option value="">${uiLabel("providerDefaultModel")}</option>${unique.map(item => `<option value="${esc(item.id)}">${esc(item.label)}</option>`).join("")}`;
133
+ select.value = current;
134
+ if (select.value !== current && current) {
135
+ select.insertAdjacentHTML("beforeend", `<option value="${esc(current)}">${esc(current)}(当前)</option>`);
136
+ select.value = current;
137
+ }
138
+ select.onchange = async () => {
139
+ const model = select.value;
140
+ if (model === current) return;
141
+ select.disabled = true;
142
+ try {
143
+ const updated = await api(`/tasks/${encodeURIComponent(task.id)}`, { method: "PATCH", body: JSON.stringify({ model }) });
144
+ state.selectedTask = { ...state.selectedTask, ...updated }; mergeTask(updated); renderConversation();
145
+ toast(model ? `已切换模型:${model}` : "已恢复 Provider 默认模型");
146
+ } catch (error) { select.value = current; toast(`模型切换失败:${error.message}`); }
147
+ finally { select.disabled = false; }
148
+ };
149
+ select.title = unique.length ? `已加载 ${unique.length} 个模型,可直接选择` : "Provider 未返回模型列表,将使用默认模型";
150
+ }
151
+ function renderComposerEffortSelect(task, value = "") {
152
+ const select = $("#composer-effort-select");
153
+ if (!select) return;
154
+ const options = [["", uiLabel("providerDefault")], ["minimal", uiLabel("minimal")], ["low", uiLabel("low")], ["medium", uiLabel("medium")], ["high", uiLabel("high")], ["xhigh", uiLabel("xhigh")], ["ultra", uiLabel("ultra")]];
155
+ const current = task.reasoning_effort || "";
156
+ select.innerHTML = options.map(([id, label]) => `<option value="${id}">${label}</option>`).join("");
157
+ select.value = value || current;
158
+ select.onchange = async () => {
159
+ const reasoning_effort = select.value;
160
+ if (reasoning_effort === current) return;
161
+ select.disabled = true;
162
+ try {
163
+ const updated = await api(`/tasks/${encodeURIComponent(task.id)}`, { method: "PATCH", body: JSON.stringify({ reasoning_effort }) });
164
+ state.selectedTask = { ...state.selectedTask, ...updated }; mergeTask(updated); renderConversation();
165
+ toast(reasoning_effort ? `思考程度:${select.options[select.selectedIndex]?.text || reasoning_effort}` : "已恢复 Provider 默认思考程度");
166
+ } catch (error) { select.value = current; toast(`思考程度切换失败:${error.message}`); }
167
+ finally { select.disabled = false; }
168
+ };
169
+ select.title = "选择 Codex 思考程度";
170
+ }
171
+ async function loadComposerModels(task) {
172
+ const cacheKey = `${task.id}:${task.provider_id || "default"}`;
173
+ if (composerModelCache.has(cacheKey)) {
174
+ const cached = composerModelCache.get(cacheKey);
175
+ renderComposerModelSelect(task, Array.isArray(cached) ? cached : cached.models || []);
176
+ renderComposerEffortSelect(task, Array.isArray(cached) ? task.reasoning_effort || "" : cached.reasoning_effort || task.reasoning_effort || "");
177
+ return;
178
+ }
179
+ if (composerModelLoads.has(cacheKey)) return;
180
+ const request = api(`/tasks/${encodeURIComponent(task.id)}/models`).then(result => {
181
+ const models = normalizeModelItems(result.models);
182
+ const settings = { models, reasoning_effort: result.reasoning_effort || "" };
183
+ composerModelCache.set(cacheKey, settings);
184
+ if (state.selectedId === task.id) {
185
+ renderComposerModelSelect(state.selectedTask, models);
186
+ renderComposerEffortSelect(state.selectedTask, settings.reasoning_effort || state.selectedTask.reasoning_effort || "");
187
+ }
188
+ }).catch(error => {
189
+ if (state.selectedId === task.id) {
190
+ const select = $("#composer-model-select");
191
+ if (select) { select.disabled = false; select.title = `模型列表读取失败:${error.message}`; }
192
+ }
193
+ }).finally(() => composerModelLoads.delete(cacheKey));
194
+ composerModelLoads.set(cacheKey, request);
195
+ }
196
+ function goalStatusLabel(value) {
197
+ if (value === "none") return ({ zh: "未设置", en: "Unset", fr: "Non défini", ja: "未設定", ko: "미설정" }[state.language] || "Unset");
198
+ const labels = { active: "running", paused: "statusStopped", blocked: "statusFailed", usageLimited: "statusRetrying", budgetLimited: "statusRetrying", complete: "statusSucceeded", none: "available" };
199
+ return labels[value] === "running" ? t("statusRunning") : labels[value] ? t(labels[value]) : value || t("unknown");
200
+ }
201
+ function renderGoalBar() {
202
+ const task = state.selectedTask;
203
+ const bar = $("#goal-bar");
204
+ if (!task) { bar.hidden = true; return; }
205
+ bar.hidden = false;
206
+ const goal = String(task.goal || "").trim();
207
+ const activeTurn = ["running", "retrying", "queued"].includes(task.status);
208
+ const goalRunning = Boolean(goal) && activeTurn && task.goal_status !== "paused";
209
+ const inputHasContent = Boolean($("#message-input")?.value.trim() || pendingAttachments.length);
210
+ $("#goal-text").textContent = goal;
211
+ $("#goal-text").classList.toggle("empty", !goal);
212
+ $("#goal-task-status").textContent = `${statusLabel(task.status)} · ${goalStatusLabel(task.goal_status || (goal ? "active" : "none"))}`;
213
+ $("#goal-run-toggle").classList.toggle("active", goalRunning);
214
+ $("#goal-run-toggle").disabled = !goal;
215
+ $("#goal-run-toggle").title = !goal ? uiLabel("setGoal") : (goalRunning ? uiLabel("goalPause") : uiLabel("goalStart"));
216
+ $("#goal-run-toggle").setAttribute("aria-label", $("#goal-run-toggle").title);
217
+ $("#goal-run-icon").textContent = goalRunning ? "Ⅱ" : "▶";
218
+ $("#goal-run-label").textContent = goalRunning ? uiLabel("pause") : uiLabel("start");
219
+ const retryToggle = $("#goal-retry-toggle");
220
+ retryToggle.disabled = !goal;
221
+ retryToggle.classList.toggle("active", Boolean(goal && task.retry_forever));
222
+ retryToggle.title = goal ? (task.retry_forever ? uiLabel("retryOff") : uiLabel("retryOn")) : uiLabel("setGoal");
223
+ retryToggle.setAttribute("aria-label", retryToggle.title);
224
+ $("#goal-retry-label").textContent = goal && task.retry_forever ? uiLabel("on") : uiLabel("off");
225
+ const liveState = $("#composer-live-state");
226
+ liveState.className = `composer-live-state ${esc(task.status || "available")}`;
227
+ $("#composer-live-state span").textContent = statusLabel(task.status);
228
+ const stopping = !state.editingQueuedId && activeTurn && !inputHasContent;
229
+ const sendLabel = state.editingQueuedId ? uiLabel("save") : stopping ? uiLabel("stopAction") : uiLabel("send");
230
+ $("#send-codex-label").textContent = sendLabel;
231
+ $("#send-codex").classList.toggle("stop-mode", stopping);
232
+ $("#send-codex").querySelector("span:last-child").textContent = stopping ? "■" : "↑";
233
+ $("#send-codex").title = state.editingQueuedId ? uiLabel("saveQueued") : (stopping ? uiLabel("stopCodex") : uiLabel("startCodex"));
234
+ $("#message-input").placeholder = state.editingQueuedId ? uiLabel("editQueued") : t("composerPlaceholder");
235
+ }
236
+ function renderQueuedMessages() {
237
+ const strip = $("#queued-messages");
238
+ if (!strip) return;
239
+ const queued = (state.selectedMessages || []).filter(message => ["queued", "dispatching"].includes(message.status));
240
+ strip.hidden = false;
241
+ if (!queued.length) {
242
+ strip.innerHTML = `<div class="queued-head"><span><i></i>${uiLabel("queue")}</span><span class="queued-head-actions"><strong>0</strong></span></div><div class="queued-empty">${uiLabel("queueEmpty")}</div>`;
243
+ stopQueueSync();
244
+ return;
245
+ }
246
+ if (!queueSyncTimer) queueSyncTimer = setInterval(syncQueuedMessages, 1500);
247
+ const visible = queued.slice(0, 4);
248
+ strip.innerHTML = `<div class="queued-head"><span><i></i>${uiLabel("queue")}</span><span class="queued-head-actions"><strong>${queued.length}</strong><button type="button" class="queue-icon danger-icon" data-queue-clear title="${uiLabel("clearQueue")}">×</button></span></div>${visible.map((message, index) => { const dispatching = message.status === "dispatching"; return `<div class="queued-message ${dispatching ? "dispatching" : ""} ${state.editingQueuedId === message.id ? "editing" : ""}"><div class="queued-message-main"><span class="queued-index">${index + 1}</span><span class="queued-body">${esc(message.body)}</span></div><div class="queued-actions"><button type="button" class="queue-icon" data-queue-dispatch="${esc(message.id)}" title="${dispatching ? uiLabel("sending") : uiLabel("dispatchNow")}" ${dispatching ? "disabled" : ""}>${dispatching ? "…" : "▶"}</button><button type="button" class="queue-icon" data-queue-edit="${esc(message.id)}" title="${uiLabel("editQueuedAction")}" ${dispatching ? "disabled" : ""}>✎</button><button type="button" class="queue-icon danger-icon" data-queue-delete="${esc(message.id)}" title="${uiLabel("deleteQueuedAction")}" ${dispatching ? "disabled" : ""}>×</button></div></div>`; }).join("")}${queued.length > visible.length ? `<div class="queued-more">${uiLabel("queueMore", { count: queued.length - visible.length })}</div>` : ""}`;
249
+ }
250
+ function renderChat() {
251
+ const stream = $("#chat-log");
252
+ const stickToBottom = state.chatSnapToBottom || stream.scrollHeight - stream.scrollTop - stream.clientHeight < 96;
253
+ if (!chatWorker) {
254
+ stream.innerHTML = `<div class="chat-empty"><p>${uiLabel("workerUnavailable") || "Chat Worker unavailable"}</p></div>`;
255
+ return;
256
+ }
257
+ const requestId = ++chatBuildRequestId;
258
+ chatWorker.postMessage({
259
+ requestId,
260
+ events: state.selectedEvents,
261
+ messages: state.selectedMessages,
262
+ rawActivity: state.rawActivity,
263
+ labels: {
264
+ fileChanged: uiLabel("fileChanged"), contextCompressed: uiLabel("contextCompressed"),
265
+ terminalTurnStarted: uiLabel("terminalTurnStarted"), terminalTurnCompleted: uiLabel("terminalTurnCompleted"),
266
+ terminalTurnAborted: uiLabel("terminalTurnAborted"), codexActivity: uiLabel("codexActivity"),
267
+ },
268
+ });
269
+ chatWorker.onmessage = event => {
270
+ if (event.data.requestId !== chatBuildRequestId || event.data.error) return;
271
+ state.chatBlocks = event.data.blocks || [];
272
+ paintVirtualChat(stickToBottom);
273
+ };
274
+ }
275
+
276
+ function renderChatBlock(block) {
277
+ if (block.role === "activities") return `<details class="activity-group"><summary><span>⌁</span>${uiLabel("activity")} <small>${block.items.length}</small></summary><div class="activity-events">${block.items.slice(-10).map(item => `<div class="activity-event">${esc(item)}</div>`).join("")}${block.items.length > 10 ? `<div class="activity-event">… ${block.items.length - 10}</div>` : ""}</div></details>`;
278
+ const deliveryLabels = { sending: uiLabel("sending"), steering: uiLabel("steering"), steered: uiLabel("steering"), queued: uiLabel("queued"), running: `${statusLabel("running")} · Codex`, failed: uiLabel("failedSend") };
279
+ const label = block.role === "user" ? (block.commandBlock ? uiLabel("commandLabel") : "") : (block.commandBlock ? uiLabel("commandResult") : uiLabel("codexPartner"));
280
+ return `<article class="message ${block.role}${block.commandBlock ? " command-message" : ""}${block.streaming ? " streaming" : ""}" data-item-id="${esc(block.itemId || "")}"><div class="message-avatar" role="img" aria-label="${block.role === "user" ? uiLabel("you") : uiLabel("codexPartner")}" title="${block.role === "user" ? uiLabel("you") : uiLabel("codexPartner")}">${block.role === "user" ? humanMarkup() : mascotMarkup("mascot-chat")}</div><div class="message-body"><div class="message-meta">${label ? `<strong>${label}</strong>` : ""}${block.origin === "terminal" ? `<span>${uiLabel("messageFromTerminal")}</span>` : ""}${block.time ? `<time datetime="${esc(block.time)}">${esc(shortDate(block.time))}</time>` : ""}</div><div class="message-content">${block.html || ""}${block.streaming ? `<span class="stream-cursor"></span>` : ""}</div>${deliveryLabels[block.delivery] ? `<div class="message-delivery ${esc(block.delivery)}" title="${esc(block.error)}">${esc(deliveryLabels[block.delivery])}</div>` : ""}</div></article>`;
281
+ }
282
+
283
+ function paintVirtualChat(stickToBottom = false) {
284
+ const stream = $("#chat-log"); const blocks = state.chatBlocks || []; const windowSize = 90;
285
+ if (!blocks.length) { stream.innerHTML = `<div class="chat-empty">${mascotMarkup("mascot-chat")}<p>${uiLabel("firstMessage")}</p></div>`; return; }
286
+ if (state.chatVirtualStart === null || stickToBottom) state.chatVirtualStart = Math.max(0, blocks.length - windowSize);
287
+ const start = Math.max(0, Math.min(state.chatVirtualStart, Math.max(0, blocks.length - windowSize)));
288
+ const end = Math.min(blocks.length, start + windowSize);
289
+ const average = Math.max(72, state.chatAverageHeight || 112);
290
+ const topHeight = start * average; const bottomHeight = (blocks.length - end) * average;
291
+ const olderControl = state.historyHasMore && start === 0 ? `<button type="button" class="chat-load-older" ${state.historyLoading ? "disabled" : ""}>${uiLabel("loadEarlierMessages")}</button>` : "";
292
+ stream.innerHTML = `<div class="chat-virtual-spacer" style="height:${topHeight}px"></div>${olderControl}${blocks.slice(start, end).map(renderChatBlock).join("")}<div class="chat-virtual-spacer" style="height:${bottomHeight}px"></div>`;
293
+ const rendered = $$(".message, .activity-group", stream);
294
+ if (rendered.length) {
295
+ const measured = rendered.reduce((total, node) => total + node.getBoundingClientRect().height + 24, 0) / rendered.length;
296
+ state.chatAverageHeight = Math.max(72, Math.min(360, state.chatAverageHeight * .75 + measured * .25));
297
+ }
298
+ $(".chat-load-older", stream)?.addEventListener("click", loadOlderTimeline);
299
+ hydrateChatFiles();
300
+ if (!stream.dataset.virtualScroll) {
301
+ stream.dataset.virtualScroll = "1";
302
+ stream.addEventListener("scroll", () => {
303
+ if (!state.chatBlocks.length) return;
304
+ const target = Math.max(0, Math.floor(stream.scrollTop / Math.max(72, state.chatAverageHeight)) - 12);
305
+ const bounded = Math.min(target, Math.max(0, state.chatBlocks.length - windowSize));
306
+ const currentStart = state.chatVirtualStart || 0;
307
+ if ((bounded === 0 && currentStart !== 0) || Math.abs(bounded - currentStart) >= 18) { state.chatVirtualStart = bounded; paintVirtualChat(false); stream.scrollTop = target * state.chatAverageHeight; }
308
+ }, { passive: true });
309
+ }
310
+ if (stickToBottom) stream.scrollTop = stream.scrollHeight;
311
+ state.chatSnapToBottom = false;
312
+ }
313
+
314
+ async function loadOlderTimeline() {
315
+ if (!state.selectedId || !state.historyHasMore || state.historyLoading) return chatHistoryLoadPromise;
316
+ const taskId = state.selectedId; const stream = $("#chat-log"); const previousHeight = stream.scrollHeight;
317
+ state.historyLoading = true; paintVirtualChat(false);
318
+ chatHistoryLoadPromise = api(`/tasks/${encodeURIComponent(taskId)}/timeline?limit=160&before=${encodeURIComponent(state.historyCursor)}`)
319
+ .then(page => {
320
+ if (state.selectedId !== taskId) return;
321
+ const existing = new Set(state.selectedEvents.map(event => `${event.stream}:${event.id}`));
322
+ const older = (page.items || []).filter(event => !existing.has(`${event.stream}:${event.id}`));
323
+ const metrics = state.selectedEvents.filter(event => event.stream === "metrics");
324
+ state.selectedEvents = [...older, ...state.selectedEvents.filter(event => event.stream !== "metrics"), ...(page.metrics || metrics)];
325
+ state.historyCursor = page.next_cursor || ""; state.historyHasMore = Boolean(page.has_more);
326
+ state.chatVirtualStart = 0; renderChat();
327
+ requestAnimationFrame(() => { stream.scrollTop = Math.max(0, stream.scrollHeight - previousHeight); });
328
+ })
329
+ .catch(error => toast(error.message))
330
+ .finally(() => { state.historyLoading = false; chatHistoryLoadPromise = null; });
331
+ return chatHistoryLoadPromise;
332
+ }
333
+ function appendStreamingDelta(payload) {
334
+ const delta = String(payload?.delta || "");
335
+ const itemId = String(payload?.item_id || "");
336
+ if (!delta || !itemId) return false;
337
+ const stream = $("#chat-log");
338
+ const articles = $$("article.message.assistant", stream);
339
+ const article = articles[articles.length - 1];
340
+ if (!article || article.dataset.itemId !== itemId) return false;
341
+ const content = $(".message-content", article);
342
+ if (!content) return false;
343
+ const stickToBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 96;
344
+ const cursor = $(".stream-cursor", content);
345
+ const fragment = document.createDocumentFragment();
346
+ delta.split("\n").forEach((part, index) => { if (index) fragment.append(document.createElement("br")); if (part) fragment.append(document.createTextNode(part)); });
347
+ content.insertBefore(fragment, cursor || null);
348
+ article.classList.add("streaming");
349
+ if (stickToBottom) stream.scrollTop = stream.scrollHeight;
350
+ return true;
351
+ }
352
+ async function hydrateChatFiles() {
353
+ for (const node of $$("[data-chat-file]")) {
354
+ if (node.dataset.loaded) continue;
355
+ node.dataset.loaded = "1";
356
+ const path = node.dataset.chatFile;
357
+ try {
358
+ const name = path.toLowerCase();
359
+ const imageMime = name.endsWith(".png") ? "image/png" : /\.jpe?g$/i.test(name) ? "image/jpeg" : name.endsWith(".gif") ? "image/gif" : name.endsWith(".webp") ? "image/webp" : name.endsWith(".bmp") ? "image/bmp" : name.endsWith(".svg") ? "image/svg+xml" : "";
360
+ const imageByName = Boolean(imageMime);
361
+ const taskId = state.selectedId;
362
+ const cacheKey = `${taskId}:${path}`;
363
+ const isImage = imageByName;
364
+ const preview = node.querySelector(".chat-file-preview");
365
+ if (isImage && preview) {
366
+ let url = chatThumbnailObjectUrls.get(cacheKey) || "";
367
+ if (!url) {
368
+ let load = chatThumbnailLoads.get(cacheKey);
369
+ if (!load) {
370
+ load = workspaceFetch(`/tasks/${encodeURIComponent(taskId)}/workspace/thumbnail?size=640&path=${encodeURIComponent(path)}`)
371
+ .then(async response => {
372
+ if (!response.ok) throw new Error("thumbnail failed");
373
+ const objectUrl = URL.createObjectURL(await response.blob());
374
+ chatThumbnailObjectUrls.set(cacheKey, objectUrl);
375
+ return objectUrl;
376
+ })
377
+ .finally(() => chatThumbnailLoads.delete(cacheKey));
378
+ chatThumbnailLoads.set(cacheKey, load);
379
+ }
380
+ url = await load;
381
+ }
382
+ preview.hidden = false;
383
+ preview.innerHTML = `<a class="chat-image-link" href="#" aria-label="${esc(path.split("/").pop() || path)}"><img src="${url}" alt="${esc(path.split("/").pop() || path)}" /></a>`;
384
+ $(".chat-image-link", preview).onclick = async event => {
385
+ event.preventDefault();
386
+ const tab = window.open("", "_blank");
387
+ try {
388
+ let original = chatFileObjectUrls.get(cacheKey) || "";
389
+ if (!original) {
390
+ const response = await workspaceFetch(`/tasks/${encodeURIComponent(taskId)}/workspace/download?path=${encodeURIComponent(path)}`);
391
+ if (!response.ok) throw new Error("download failed");
392
+ let blob = await response.blob();
393
+ if (imageMime && !blob.type.startsWith("image/")) blob = new Blob([blob], { type: imageMime });
394
+ original = URL.createObjectURL(blob); chatFileObjectUrls.set(cacheKey, original);
395
+ }
396
+ if (tab) tab.location.href = original; else window.open(original, "_blank", "noopener");
397
+ } catch (error) { if (tab) tab.close(); toast(error.message); }
398
+ };
399
+ } else {
400
+ node.classList.add("file-only"); node.tabIndex = 0; node.setAttribute("role", "link");
401
+ node.onclick = async () => {
402
+ const tab = window.open("", "_blank");
403
+ try {
404
+ const response = await workspaceFetch(`/tasks/${encodeURIComponent(taskId)}/workspace/download?path=${encodeURIComponent(path)}`);
405
+ if (!response.ok) throw new Error("download failed");
406
+ const url = URL.createObjectURL(await response.blob());
407
+ if (tab) tab.location.href = url; else window.open(url, "_blank", "noopener");
408
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
409
+ } catch (error) { if (tab) tab.close(); toast(error.message); }
410
+ };
411
+ }
412
+ } catch (_) { node.classList.add("unavailable"); }
413
+ }
414
+ }
415
+ function localizeInspectorText(root) {
416
+ const keys = [
417
+ "session", "state", "thread", "sshHost", "workspacePath", "goal", "noGoal", "progress",
418
+ "runHistory", "noRunHistory", "threadControls", "copySession", "compactContext", "moveTrash",
419
+ "workspaceFiles", "changeDirectory", "upload", "edit", "download", "emptyDirectory", "back",
420
+ "archive", "unarchive", "enableMemory", "disableMemory", "autoProvider",
421
+ ];
422
+ const labels = {
423
+ SESSION: uiLabel("session"), 状态: uiLabel("state"), Thread: uiLabel("thread"),
424
+ "SSH 主机": uiLabel("sshHost"), 工作目录: uiLabel("workspacePath"), GOAL: uiLabel("goal"),
425
+ "没有设置 Goal": uiLabel("noGoal"), 进度: uiLabel("progress"), "运行记录": uiLabel("runHistory"),
426
+ "暂无运行记录": uiLabel("noRunHistory"), "THREAD CONTROLS": uiLabel("threadControls"),
427
+ "复制会话": uiLabel("copySession"), "压缩上下文": uiLabel("compactContext"),
428
+ "移到回收站": uiLabel("moveTrash"), "WORKSPACE FILES": uiLabel("workspaceFiles"), "更改目录": uiLabel("changeDirectory"),
429
+ "上传": uiLabel("upload"), "编辑": uiLabel("edit"), "下载": uiLabel("download"), "目录为空,可将文件拖到这里上传。": uiLabel("emptyDirectory"),
430
+ none: goalStatusLabel("none"),
431
+ "返回上级": uiLabel("back"),
432
+ };
433
+ // Workspace DOM is preserved across language changes. Accept every previous
434
+ // translation as input so repeated switching never leaves mixed languages.
435
+ for (const key of keys) {
436
+ for (const value of Object.values(UI_LABELS[key] || {})) labels[value] = uiLabel(key);
437
+ }
438
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
439
+ const nodes = [];
440
+ while (walker.nextNode()) nodes.push(walker.currentNode);
441
+ for (const node of nodes) {
442
+ const value = node.nodeValue.trim();
443
+ if (labels[value]) node.nodeValue = node.nodeValue.replace(value, labels[value]);
444
+ else {
445
+ const attemptPrefix = Object.values(UI_LABELS.attempt || {}).find(prefix => value.startsWith(`${prefix} `));
446
+ if (attemptPrefix) node.nodeValue = node.nodeValue.replace(attemptPrefix, uiLabel("attempt"));
447
+ }
448
+ }
449
+ }
450
+ function renderInspector() {
451
+ const task = state.selectedTask; if (!task) return;
452
+ const content = $("#inspector-content");
453
+ const workspaceSection = $("#workspace-section", content);
454
+ const workspaceError = $(".workspace-error", content);
455
+ const sessions = task.sessions || state.sessions.filter(s => s.task_id === task.id);
456
+ const threadId = task.codex_session_id || "";
457
+ const memoryEnabled = task.memory_mode === "enabled";
458
+ const archiveAction = task.archived ? "unarchive" : "archive";
459
+ const archiveLabel = task.archived ? uiLabel("unarchive") : uiLabel("archive");
460
+ const memoryAction = memoryEnabled ? "memory-disable" : "memory-enable";
461
+ const memoryLabel = memoryEnabled ? uiLabel("disableMemory") : uiLabel("enableMemory");
462
+ content.innerHTML = `<section class="inspector-section"><span class="inspector-label">SESSION</span><div class="inspector-row"><span>状态</span>${status(task.status)}</div><div class="inspector-row"><span>Thread</span><button class="path-button thread-copy" data-copy-thread="${esc(threadId)}" title="${threadId ? "点击复制 Thread ID" : "Thread 尚未创建"}" ${threadId ? "" : "disabled"}>${esc(threadId || "未创建")}</button></div>${task.ssh_host ? `<div class="inspector-row"><span>SSH 主机</span><button class="path-button" data-panel="ssh">${esc(task.ssh_host)}</button></div>` : ""}<div class="inspector-row"><span>工作目录</span><button class="path-button" data-workspace-change title="重新设置工作目录">${esc(task.workspace)}</button></div></section><section class="inspector-section"><span class="inspector-label">GOAL</span><p class="inspector-goal">${esc(task.goal || "没有设置 Goal")}</p><div class="inspector-row"><span>进度</span><span>${esc(task.goal_status || "none")}</span></div></section><details class="inspector-section run-history"><summary><span>运行记录</span><small>${sessions.length}</small></summary><div class="run-history-list">${sessions.length ? sessions.map(session => `<div class="run-row"><span class="run-dot ${esc(session.status)}"></span><span><strong>尝试 ${session.attempt || 0}</strong><small>${esc(session.provider_id || "自动 Provider")} · ${esc(shortDate(session.started_at))}</small></span>${status(session.status)}</div>`).join("") : `<p class="muted">暂无运行记录</p>`}</div></details><section class="inspector-section"><span class="inspector-label">THREAD CONTROLS</span><div class="inspector-actions"><button data-thread-action="fork">复制会话</button><button data-thread-action="compact">压缩上下文</button><button data-thread-action="${archiveAction}">${archiveLabel}</button><button data-thread-action="delete" class="danger">移到回收站</button><button data-thread-action="${memoryAction}" title="当前状态:记忆${memoryEnabled ? "已开启" : "已关闭"}">${memoryLabel}</button></div></section>`;
463
+ if (workspaceSection?.dataset.taskId === task.id) content.append(workspaceSection);
464
+ else if (workspaceError?.dataset.taskId === task.id) content.append(workspaceError);
465
+ localizeInspectorText(content);
466
+ }
467
+ async function loadWorkspace(path = "") {
468
+ if (!state.selectedTask) return;
469
+ const taskId = state.selectedTask.id;
470
+ const requestId = ++state.workspaceRequestId;
471
+ state.workspacePath = path;
472
+ $(".workspace-error")?.remove();
473
+ try {
474
+ const data = await api(`/tasks/${taskId}/workspace?path=${encodeURIComponent(path)}`);
475
+ if (requestId !== state.workspaceRequestId || state.selectedTask?.id !== taskId) return;
476
+ renderWorkspace(data, taskId);
477
+ } catch (error) {
478
+ if (requestId !== state.workspaceRequestId || state.selectedTask?.id !== taskId) return;
479
+ const existing = $("#workspace-section");
480
+ if (existing) existing.remove();
481
+ $(".workspace-error")?.remove();
482
+ $("#inspector-content").insertAdjacentHTML("beforeend", `<p class="inspector-error workspace-error" data-task-id="${esc(taskId)}">${esc(error.message)}</p>`);
483
+ }
484
+ }
485
+ function renderWorkspace(data, taskId = state.selectedTask?.id || "") {
486
+ const existing = $("#workspace-section"); if (existing) existing.remove();
487
+ $(".workspace-error")?.remove();
488
+ const path = data.entry.path || "";
489
+ const isDirectory = data.entry.kind === "directory";
490
+ state.workspaceFile = isDirectory ? null : { path, name: data.entry.name, size: data.entry.size || 0, content: data.content || "", editable: data.editable === true };
491
+ const workspaceLocked = ["running", "retrying", "queued"].includes(state.selectedTask?.status);
492
+ const parts = path.split("/").filter(Boolean);
493
+ const breadcrumbs = [`<button class="workspace-crumb ${parts.length ? "" : "current"}" data-browse="" title="工作区根目录">/</button>`];
494
+ parts.forEach((part, index) => {
495
+ const target = parts.slice(0, index + 1).join("/");
496
+ breadcrumbs.push(`<span class="workspace-crumb-separator">/</span><button class="workspace-crumb ${index === parts.length - 1 ? "current" : ""}" data-browse="${esc(target)}">${esc(part)}</button>`);
497
+ });
498
+ const parent = parts.slice(0, -1).join("/");
499
+ const toolbar = `<div class="workspace-toolbar"><button type="button" class="workspace-tool" data-workspace-change title="${workspaceLocked ? "停止当前 Codex turn 后更改工作目录" : "选择工作目录"}" ${workspaceLocked ? "disabled" : ""}><span aria-hidden="true">▰</span> 更改目录</button>${isDirectory ? `<button type="button" class="workspace-tool" data-workspace-upload title="上传文件到当前目录"><span aria-hidden="true">↑</span> 上传</button>` : `${data.editable === true ? `<button type="button" class="workspace-tool" data-workspace-edit="${esc(path)}" title="编辑当前文件"><span aria-hidden="true">✎</span> 编辑</button>` : ""}<button type="button" class="workspace-tool" data-workspace-download="${esc(path)}" title="下载当前文件"><span aria-hidden="true">↓</span> 下载</button>`}</div>`;
500
+ const entries = data.entry.kind === "file"
501
+ ? `<div class="file-preview"><div class="file-preview-head"><span>▣ ${esc(data.entry.name)}</span><small>${formatBytes(data.entry.size)}</small></div><pre>${esc(data.content || "")}</pre></div>`
502
+ : `<div class="workspace-browser" id="workspace-browser">${(data.entries || []).map(entry => `<div class="file-row ${entry.kind}"><button type="button" class="file-open" data-browse="${esc(entry.path)}"><span>${entry.kind === "directory" ? "▰" : "▤"}</span><span>${esc(entry.name)}</span><small>${entry.kind === "directory" ? "目录" : formatBytes(entry.size)}</small></button>${entry.kind === "file" ? `<button type="button" class="file-download" data-workspace-download="${esc(entry.path)}" title="下载 ${esc(entry.name)}" aria-label="下载 ${esc(entry.name)}">↓</button>` : ""}</div>`).join("") || `<p class="muted">目录为空,可将文件拖到这里上传。</p>`}</div>`;
503
+ $("#inspector-content").insertAdjacentHTML("beforeend", `<section class="inspector-section workspace-section" id="workspace-section" data-task-id="${esc(taskId)}" data-entry-kind="${esc(data.entry.kind)}" data-path="${esc(path)}"><span class="inspector-label">WORKSPACE FILES</span>${toolbar}<nav class="workspace-breadcrumbs" aria-label="工作区路径">${breadcrumbs.join("")}</nav>${path ? `<button class="file-up" data-browse="${esc(parent)}">‹ 返回上级</button>` : ""}${entries}</section>`);
504
+ const workspace = $("#workspace-section");
505
+ localizeInspectorText(workspace);
506
+ workspace.querySelector("nav")?.setAttribute("aria-label", uiLabel("workspacePath"));
507
+ workspace.querySelector("[data-workspace-change]")?.setAttribute("title", uiLabel("chooseWorkspace"));
508
+ workspace.querySelector("[data-workspace-upload]")?.setAttribute("title", uiLabel("upload"));
509
+ workspace.querySelector("[data-workspace-edit]")?.setAttribute("title", uiLabel("edit"));
510
+ workspace.querySelector("[data-workspace-download]")?.setAttribute("title", uiLabel("download"));
511
+ workspace.querySelector(".file-up")?.setAttribute("title", uiLabel("back"));
512
+ }
513
+
514
+ async function loadWorkspacePicker(path = "") {
515
+ if (!state.selectedId) return;
516
+ const content = $("#workspace-picker-content");
517
+ if (content) content.innerHTML = `<p class="muted">${uiLabel("readingDirectory")}</p>`;
518
+ try {
519
+ const data = await api(`/tasks/${state.selectedId}/workspace-picker?path=${encodeURIComponent(path)}`);
520
+ state.workspacePickerPath = data.path;
521
+ const input = $("#workspace-picker-input"); if (input) input.value = data.path;
522
+ if (!content) return;
523
+ const roots = (data.roots || []).map(root => `<button type="button" class="workspace-root" data-picker-browse="${esc(root.path)}" title="${esc(root.path)}">${esc(root.name)}</button>`).join("");
524
+ const entries = (data.entries || []).map(entry => `<button type="button" class="workspace-picker-row" data-picker-browse="${esc(entry.path)}"><span aria-hidden="true">▰</span><span>${esc(entry.name)}</span><small>${uiLabel("open")}</small></button>`).join("") || `<p class="muted">${uiLabel("noSubdirectories")}</p>`;
525
+ content.innerHTML = `<div class="workspace-picker-roots">${roots}</div><div class="workspace-picker-location">${data.parent ? `<button type="button" class="workspace-picker-up" data-picker-browse="${esc(data.parent)}" title="返回上级">‹</button>` : ""}<code title="${esc(data.path)}">${esc(data.path)}</code></div><div class="workspace-picker-list">${entries}</div>`;
526
+ const select = $("#workspace-picker-select"); if (select) select.disabled = false;
527
+ } catch (error) {
528
+ if (content) content.innerHTML = `<p class="inspector-error">${esc(error.message)}</p>`;
529
+ const select = $("#workspace-picker-select"); if (select) select.disabled = true;
530
+ }
531
+ }
532
+
533
+ function openWorkspacePicker() {
534
+ if (!state.selectedTask) return;
535
+ if (["running", "retrying", "queued"].includes(state.selectedTask.status)) return toast("请先停止当前 Codex turn,再更改工作目录");
536
+ state.workspacePickerPath = state.selectedTask.workspace;
537
+ openDrawer(`<h2>${uiLabel("chooseWorkspace")}</h2><form id="workspace-picker-form" class="workspace-picker-form"><label for="workspace-picker-input">${uiLabel("serverDirectory")}</label><div><input id="workspace-picker-input" value="${esc(state.selectedTask.workspace)}" autocomplete="off" spellcheck="false"/><button class="secondary" type="submit">${uiLabel("go")}</button></div></form><div id="workspace-picker-content" class="workspace-picker-content"><p class="muted">${uiLabel("readingDirectory")}</p></div><div class="form-actions workspace-picker-actions"><button type="button" class="secondary" id="cancel">${uiLabel("cancel")}</button><button type="button" class="primary" id="workspace-picker-select" disabled>${uiLabel("selectCurrentDirectory")}</button></div>`);
538
+ $("#workspace-picker-form").onsubmit = event => { event.preventDefault(); loadWorkspacePicker($("#workspace-picker-input").value.trim()); };
539
+ loadWorkspacePicker(state.workspacePickerPath);
540
+ }
541
+
542
+ async function selectWorkspaceDirectory() {
543
+ if (!state.selectedId || !state.workspacePickerPath) return;
544
+ const task = await api(`/tasks/${state.selectedId}`, { method: "PATCH", body: JSON.stringify({ workspace: state.workspacePickerPath }) });
545
+ resetWorkspaceBrowser();
546
+ state.selectedTask = { ...state.selectedTask, ...task };
547
+ mergeTask(task);
548
+ closeDrawer(); renderConversation(); renderSessionList();
549
+ await loadWorkspace("");
550
+ toast(`工作目录已切换到 ${task.workspace}`);
551
+ }
552
+
553
+ async function workspaceFetch(path, options = {}, canPrompt = true) {
554
+ const headers = { ...(options.headers || {}) };
555
+ if (authToken) headers.Authorization = `Bearer ${authToken}`;
556
+ const response = await fetch(`/api${path}`, { ...options, headers });
557
+ if (response.status === 401 && canPrompt) {
558
+ const token = await requestAccessToken();
559
+ if (token) { authToken = token.trim(); localStorage.setItem("codex-dashboard-token", authToken); return workspaceFetch(path, options, false); }
560
+ }
561
+ return response;
562
+ }
563
+
564
+ async function workspaceResponseError(response) {
565
+ const payload = await response.json().catch(() => ({ detail: response.statusText }));
566
+ return Error(payload.detail || response.statusText);
567
+ }
568
+
569
+ async function uploadWorkspaceFile(taskId, destination, file, overwrite = false) {
570
+ const query = new URLSearchParams({ path: destination, filename: file.name, overwrite: String(overwrite) });
571
+ const response = await workspaceFetch(`/tasks/${taskId}/workspace/upload?${query}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: file });
572
+ if (response.status === 409 && !overwrite) {
573
+ const conflict = await workspaceResponseError(response);
574
+ if (await appConfirm(uiLabel("overwriteFileConfirm", { name: file.name }))) return uploadWorkspaceFile(taskId, destination, file, true);
575
+ return { skipped: true, error: conflict.message };
576
+ }
577
+ if (!response.ok) throw await workspaceResponseError(response);
578
+ return response.json();
579
+ }
580
+
581
+ async function uploadWorkspaceFiles(files, destination = state.workspacePath) {
582
+ if (!state.selectedId || !files.length || state.workspaceUploading) return;
583
+ const taskId = state.selectedId;
584
+ state.workspaceUploading = true;
585
+ const button = $("[data-workspace-upload]");
586
+ if (button) { button.disabled = true; button.innerHTML = `<span aria-hidden="true">↑</span> 0/${files.length}`; }
587
+ let uploaded = 0; let skipped = 0;
588
+ try {
589
+ for (const [index, file] of [...files].entries()) {
590
+ if (button) button.innerHTML = `<span aria-hidden="true">↑</span> ${index + 1}/${files.length}`;
591
+ const result = await uploadWorkspaceFile(taskId, destination, file);
592
+ if (result?.skipped) skipped += 1; else uploaded += 1;
593
+ }
594
+ if (state.selectedId === taskId) await loadWorkspace(destination);
595
+ toast(`已上传 ${uploaded} 个文件${skipped ? `,跳过 ${skipped} 个` : ""}`);
596
+ } catch (error) {
597
+ toast(`上传失败:${error.message}`);
598
+ if (state.selectedId === taskId) await loadWorkspace(destination);
599
+ } finally {
600
+ state.workspaceUploading = false;
601
+ const input = $("#workspace-upload-input"); if (input) input.value = "";
602
+ }
603
+ }
604
+
605
+ async function downloadWorkspaceFile(path) {
606
+ if (!state.selectedId) return;
607
+ const response = await workspaceFetch(`/tasks/${state.selectedId}/workspace/download?path=${encodeURIComponent(path)}`);
608
+ if (!response.ok) throw await workspaceResponseError(response);
609
+ const blob = await response.blob();
610
+ const url = URL.createObjectURL(blob);
611
+ const link = document.createElement("a");
612
+ link.href = url; link.download = path.split("/").filter(Boolean).pop() || "download";
613
+ document.body.append(link); link.click(); link.remove();
614
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
615
+ }
616
+ function openWorkspaceFileEditor(path) {
617
+ const file = state.workspaceFile;
618
+ if (!file || file.path !== path || !file.editable) return toast(uiLabel("unsupportedFileEdit"));
619
+ openDrawer(`<div class="drawer-heading"><div><h2>${uiLabel("editFile")}</h2><p class="drawer-meta">${esc(file.path)} · ${formatBytes(file.size)}</p></div></div><form id="workspace-file-form" class="form unified-editor-form workspace-file-form"><div class="editor-field"><span class="form-label">${uiLabel("content")}</span>${textEditorMarkup("workspace-file-editor")}</div><div class="form-actions"><button type="button" class="secondary" id="cancel">${uiLabel("cancel")}</button><button type="submit" class="primary">${uiLabel("save")} <kbd>Ctrl S</kbd></button></div></form>`, { editor: true });
620
+ const editor = mountTextEditor("#workspace-file-editor", { filename: file.path, value: file.content });
621
+ $("#cancel").onclick = closeDrawer;
622
+ $("#workspace-file-form").onsubmit = async event => {
623
+ event.preventDefault(); const restore = setFormBusy(event.target, uiLabel("saving")); const content = editor.getValue();
624
+ try {
625
+ const response = await workspaceFetch(`/tasks/${state.selectedId}/workspace/file?path=${encodeURIComponent(path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }) });
626
+ if (!response.ok) throw await workspaceResponseError(response);
627
+ closeDrawer(); await loadWorkspace(path); toast(uiLabel("fileSaved"));
628
+ } catch (error) { restore(); toast(uiLabel("saveFailed", { message: error.message })); }
629
+ };
630
+ }
631
+ function commandToken(value) { return value.trim().slice(1).split(/\s+/, 1)[0].toLowerCase(); }
632
+ function commandMatches(value) {
633
+ const token = commandToken(value); const commands = state.commands || [];
634
+ if (!token) return commands;
635
+ return commands.filter(command => command.name.startsWith(token) || (command.aliases || []).some(alias => alias.startsWith(token)));
636
+ }
637
+ function renderCommandPalette() {
638
+ const input = $("#message-input"); const palette = $("#command-palette");
639
+ if (!input || !input.value.startsWith("/") || input.value.includes("\n")) { palette.hidden = true; return; }
640
+ const matches = commandMatches(input.value); if (!matches.length) { palette.hidden = true; return; }
641
+ state.commandIndex = Math.max(0, Math.min(state.commandIndex, matches.length - 1));
642
+ palette.innerHTML = matches.slice(0, 18).map((command, index) => `<button type="button" class="command-option ${index === state.commandIndex ? "active" : ""}" data-command-index="${index}" role="option" aria-selected="${index === state.commandIndex}"><span class="command-name">/${esc(command.name)} <small>${esc(command.args || "")}</small></span><span class="command-description">${esc(command.description)}${command.destructive ? " · 需要确认" : ""}</span></button>`).join("");
643
+ palette.hidden = false;
644
+ }
645
+ function selectedCommand() { const matches = commandMatches($("#message-input").value); return matches[state.commandIndex] || matches[0]; }
646
+ function completeCommand(command = selectedCommand()) {
647
+ if (!command) return; const input = $("#message-input"); input.value = `/${command.name}${command.args ? " " : ""}`; input.focus(); resizeComposerInput(input); renderCommandPalette();
648
+ }
649
+ async function applyCommandAction(result) {
650
+ const action = result?.ui_action; if (!action) return;
651
+ if (action === "new_conversation") return createQuickSession();
652
+ if (action === "select_task" && result.task_id) { await refresh(false); await selectSession(result.task_id); $("#message-input").focus(); return; }
653
+ if (action === "clear_selection") { state.selectedId = null; state.selectedTask = null; showEmptyConversation(); return refresh(false); }
654
+ if (action === "refresh") return refresh(false);
655
+ if (action === "open_panel") return openPanel(result.panel || "skills");
656
+ if (action === "open_inspector") { state.inspectorClosed = false; setInspectorOpen(true); return; }
657
+ if (action === "open_workspace") { state.inspectorClosed = false; setInspectorOpen(true); return loadWorkspace(state.workspacePath || ""); }
658
+ if (action === "focus_composer") { $("#message-input").focus(); return; }
659
+ if (action === "attach_text") { pendingAttachments = [result.attachment]; renderComposerAttachments(); $("#message-input").focus(); return; }
660
+ if (action === "copy_last") {
661
+ const last = [...(state.selectedEvents || [])].reverse().find(event => isAssistantEvent(event) && eventText(event.payload));
662
+ if (!last) return toast("还没有可复制的 Codex 回复");
663
+ try { await navigator.clipboard.writeText(eventText(last.payload)); toast("已复制最近一条回复"); } catch (_) { toast("浏览器拒绝了剪贴板访问"); }
664
+ return;
665
+ }
666
+ if (action === "toggle_raw") { const value = result.value === "on" ? true : result.value === "off" ? false : !state.rawActivity; state.rawActivity = value; return renderChat(); }
667
+ if (action === "theme") { const value = result.value === "toggle" ? (document.documentElement.dataset.theme === "wasteland" ? "dark" : "wasteland") : result.value; setTheme(value, true); return; }
668
+ if (action === "title") { const value = result.value === "toggle" ? localStorage.getItem("codex-dashboard-title") !== "on" : result.value !== "off"; localStorage.setItem("codex-dashboard-title", value ? "on" : "off"); document.title = value && state.selectedTask ? `${state.selectedTask.name || "Codex"} · Codex Partner` : "Codex Partner"; return; }
669
+ if (action === "vim") { localStorage.setItem("codex-dashboard-vim", result.value === "off" ? "off" : "on"); return toast("Vim 模式标记已更新"); }
670
+ if (action === "show_keymap") return toast("Enter 发送到当前 turn · Alt+Enter 排到下一轮 · Shift+Enter 换行 · Esc 停止 · 空输入框 ↑/↓ 历史");
671
+ if (action === "disconnect") { socketPaused = true; clearTimeout(socketReconnectTimer); if (socket) { socket.onclose = null; socket.close(); socket = null; } setRealtimeChannel("task", "paused"); return toast("当前会话的实时连接已暂停"); }
672
+ }
673
+ async function submitSlashCommand(raw) {
674
+ if (!state.selectedId) return;
675
+ const clientMessageId = crypto.randomUUID ? crypto.randomUUID() : String(Date.now());
676
+ let result = await api(`/tasks/${state.selectedId}/commands`, { method: "POST", body: JSON.stringify({ command: raw, client_message_id: clientMessageId }) });
677
+ if (result.requires_confirmation && await appConfirm(uiLabel("destructiveCommandConfirm", { command: raw.split(/\s+/)[0] }), { danger: true })) result = await api(`/tasks/${state.selectedId}/commands`, { method: "POST", body: JSON.stringify({ command: raw, confirmed: true, client_message_id: `${clientMessageId}-confirmed` }) });
678
+ if (!result.ok) toast(result.message || "命令执行失败");
679
+ await applyCommandAction(result);
680
+ if (result.ok && !["new_conversation", "select_task", "clear_selection", "disconnect", "open_panel", "open_inspector", "attach_text", "focus_composer"].includes(result.ui_action)) await refresh(false);
681
+ return result;
682
+ }
683
+ function connectOverviewSocket() {
684
+ if (overviewSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(overviewSocket.readyState)) return;
685
+ if (realtimeChannels.overview !== "reconnecting") setRealtimeChannel("overview", "connecting");
686
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
687
+ const token = authToken ? `?token=${encodeURIComponent(authToken)}` : "";
688
+ overviewSocket = new WebSocket(`${scheme}://${location.host}/ws/overview${token}`);
689
+ overviewSocket.onopen = () => { noteRealtime("overview"); setRealtimeChannel("overview", "live"); };
690
+ overviewSocket.onmessage = event => {
691
+ noteRealtime("overview"); setRealtimeChannel("overview", "live");
692
+ const data = JSON.parse(event.data);
693
+ if (data.type === "overview_snapshot") {
694
+ mergeOverviewSnapshot(data.tasks || []);
695
+ renderSessionList(); renderSidebarStats();
696
+ return;
697
+ }
698
+ if (data.type === "task_status" && data.task) {
699
+ mergeTask(data.task);
700
+ renderSessionList(); renderSidebarStats();
701
+ if (state.selectedId === data.task.id && state.selectedTask) {
702
+ const workspaceChanged = Boolean(data.task.workspace && data.task.workspace !== state.selectedTask.workspace);
703
+ if (workspaceChanged) resetWorkspaceBrowser();
704
+ state.selectedTask = { ...state.selectedTask, ...data.task };
705
+ renderConversation();
706
+ if (workspaceChanged) loadWorkspace("");
707
+ }
708
+ }
709
+ if (data.type === "task_removed" && data.task_id) {
710
+ state.tasks = state.tasks.filter(task => task.id !== data.task_id);
711
+ if (state.selectedId === data.task_id) {
712
+ state.selectedId = null; state.selectedTask = null; state.selectedMessages = [];
713
+ if (socketTaskId === data.task_id && socket) { socket.onclose = null; socket.close(); socket = null; }
714
+ showEmptyConversation();
715
+ }
716
+ renderSessionList(); renderSidebarStats();
717
+ }
718
+ };
719
+ overviewSocket.onerror = () => setRealtimeChannel("overview", "reconnecting");
720
+ overviewSocket.onclose = () => {
721
+ overviewSocket = null;
722
+ setRealtimeChannel("overview", "reconnecting");
723
+ clearTimeout(overviewReconnectTimer);
724
+ overviewReconnectTimer = setTimeout(() => { if (document.visibilityState !== "hidden") connectOverviewSocket(); }, 1200);
725
+ };
726
+ }
727
+ function connectSocket(id, reconnect = false) {
728
+ clearTimeout(socketReconnectTimer);
729
+ if (!reconnect) socketPaused = false;
730
+ if (socket) { socket.onclose = null; socket.close(); }
731
+ setRealtimeChannel("task", reconnect ? "reconnecting" : "connecting");
732
+ const scheme = location.protocol === "https:" ? "wss" : "ws"; const token = authToken ? `?token=${encodeURIComponent(authToken)}` : "";
733
+ const nextSocket = new WebSocket(`${scheme}://${location.host}/ws/tasks/${id}${token}`);
734
+ socket = nextSocket; socketTaskId = id;
735
+ nextSocket.onopen = () => { noteRealtime("task"); setRealtimeChannel("task", "live"); if (state.selectedId === id && reconnect) refreshSelectedConversation(id); };
736
+ nextSocket.onmessage = async event => {
737
+ noteRealtime("task"); setRealtimeChannel("task", "live");
738
+ const data = JSON.parse(event.data);
739
+ if (data.type === "snapshot" && state.selectedId === id) {
740
+ state.selectedTask = { ...state.selectedTask, ...(data.task || {}) };
741
+ if (data.messages) replaceTaskMessages(data.messages);
742
+ renderConversation();
743
+ return;
744
+ }
745
+ if ((data.type === "task_patch" || data.type === "task_status") && state.selectedId === id) {
746
+ const patch = data.type === "task_patch" ? (data.patch || {}) : (data.task || {});
747
+ state.selectedTask = { ...state.selectedTask, ...patch };
748
+ mergeTask({ id, ...patch });
749
+ renderConversation(); renderSessionList(); renderSidebarStats();
750
+ return;
751
+ }
752
+ if (data.type === "workspace_changed" && state.selectedId === id) {
753
+ if (!state.workspaceUploading) loadWorkspace(state.workspacePath || "");
754
+ return;
755
+ }
756
+ if (data.type === "event" && state.selectedId === id) {
757
+ state.selectedEvents.push({ session_id: data.session_id, ts: data.ts, stream: data.stream, payload: JSON.stringify(data.payload) });
758
+ state.runtimeMetrics = calculateRuntimeMetrics(state.selectedTask, state.selectedEvents);
759
+ renderConnectionStatus();
760
+ renderContextUsage();
761
+ const phase = activityPhase(data.payload);
762
+ if (phase) { livePhases.set(id, phase); renderTurnProgress(); }
763
+ if (String(data.payload?.type || "").toLowerCase() !== "agent_delta" || !appendStreamingDelta(data.payload)) scheduleRenderChat();
764
+ if (!isAssistantEvent({ stream: data.stream, payload: data.payload }) && !isUserEvent({ stream: data.stream, payload: data.payload })) showActivity(data.payload);
765
+ }
766
+ if (data.type === "message" && state.selectedId === id) { upsertTaskMessage(data); renderQueuedMessages(); scheduleRenderChat(); }
767
+ if (data.type === "message_removed" && state.selectedId === id) { removeTaskMessage(data.message_id); if (state.editingQueuedId === data.message_id) { state.editingQueuedId = null; $("#message-input").value = ""; } renderQueuedMessages(); scheduleRenderChat(); }
768
+ if (data.type === "server_request" && state.selectedId === id) { livePhases.set(id, "phaseInteraction"); renderTurnProgress(); }
769
+ if ((data.type === "session" || data.type === "provider_failover") && state.selectedId === id) {
770
+ if (data.type === "provider_failover") livePhases.set(id, "phaseProvider");
771
+ await refreshSelectedConversation(id);
772
+ }
773
+ };
774
+ nextSocket.onerror = () => { if (socket === nextSocket && !socketPaused) setRealtimeChannel("task", "reconnecting"); };
775
+ nextSocket.onclose = () => {
776
+ if (socket === nextSocket) socket = null;
777
+ if (state.selectedId !== id) { setRealtimeChannel("task", "idle"); return; }
778
+ if (socketPaused) { setRealtimeChannel("task", "paused"); return; }
779
+ setRealtimeChannel("task", "reconnecting");
780
+ socketReconnectTimer = setTimeout(() => connectSocket(id, true), 900);
781
+ };
782
+ }
783
+ function showActivity(payload) {
784
+ const task = state.selectedTask;
785
+ if (!task || !["running", "retrying", "queued"].includes(task.status)) return;
786
+ const text = activityPhase(payload) || eventText(payload);
787
+ if (!text) return;
788
+ livePhases.set(task.id, String(text).replace(/\s+/g, " ").trim().slice(0, 180));
789
+ renderTurnProgress();
790
+ }
791
+ function terminalStatus(label, kind = "") { const node = $("#terminal-status"); node.textContent = label; node.className = `terminal-status ${kind}`; }
792
+ function ensureTerminalEmulator() {
793
+ if (terminalEmulator) return true;
794
+ if (!window.Terminal || !window.FitAddon || !window.Unicode11Addon || !window.WebLinksAddon) {
795
+ terminalStatus(uiLabel("terminalModuleError"), "error");
796
+ toast(uiLabel("terminalModuleToast"));
797
+ return false;
798
+ }
799
+ terminalEmulator = new window.Terminal({
800
+ cursorBlink: true,
801
+ convertEol: false,
802
+ scrollback: 10000,
803
+ allowProposedApi: true,
804
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
805
+ fontSize: 13,
806
+ lineHeight: 1.35,
807
+ theme: { background: "#070a0b", foreground: "#d5e6d0", cursor: "#d6f77f", selectionBackground: "#52683e88" },
808
+ });
809
+ terminalFitAddon = new window.FitAddon.FitAddon();
810
+ terminalEmulator.loadAddon(terminalFitAddon);
811
+ terminalEmulator.loadAddon(new window.Unicode11Addon.Unicode11Addon());
812
+ terminalEmulator.loadAddon(new window.WebLinksAddon.WebLinksAddon());
813
+ terminalEmulator.open($("#terminal-screen"));
814
+ terminalEmulator.onData(data => sendTerminal({ type: "input", data }));
815
+ terminalEmulator.onResize(({ cols, rows }) => sendTerminal({ type: "resize", cols, rows }));
816
+ return true;
817
+ }
818
+ function fitTerminal() {
819
+ if (!terminalFitAddon || !terminalEmulator) return;
820
+ try { terminalFitAddon.fit(); } catch (_) { /* The modal may not have layout dimensions yet. */ }
821
+ }
822
+ function sendTerminal(payload) {
823
+ if (terminalSocket?.readyState !== WebSocket.OPEN) return false;
824
+ terminalSocket.send(JSON.stringify(payload));
825
+ return true;
826
+ }
827
+ function sendTerminalResize() {
828
+ fitTerminal();
829
+ if (!terminalSocket || terminalSocket.readyState !== WebSocket.OPEN || !terminalEmulator) return;
830
+ sendTerminal({ type: "resize", cols: terminalEmulator.cols, rows: terminalEmulator.rows });
831
+ }
832
+ function connectTerminal(taskId, reconnect = false) {
833
+ if (!taskId) return toast(uiLabel("selectSessionFirst"));
834
+ if (!ensureTerminalEmulator()) return;
835
+ clearTimeout(terminalReconnectTimer);
836
+ if (terminalSocket) { terminalSocket.onclose = null; terminalSocket.close(); terminalSocket = null; }
837
+ terminalTaskId = taskId;
838
+ terminalShouldReconnect = true;
839
+ if (reconnect) terminalEmulator.write(`\r\n[${uiLabel("terminalReconnectNotice")}]\r\n`);
840
+ else terminalEmulator.reset();
841
+ terminalStatus(reconnect ? uiLabel("terminalReconnecting") : uiLabel("terminalConnecting"), reconnect ? "reconnecting" : "");
842
+ setRealtimeChannel("terminal", reconnect ? "reconnecting" : "connecting");
843
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
844
+ const token = authToken ? `?token=${encodeURIComponent(authToken)}` : "";
845
+ const nextSocket = new WebSocket(`${scheme}://${location.host}/ws/terminal/${encodeURIComponent(taskId)}${token}`);
846
+ terminalSocket = nextSocket;
847
+ nextSocket.onopen = () => { noteRealtime("terminal"); terminalStatus(uiLabel("terminalConnecting")); sendTerminalResize(); terminalEmulator.focus(); };
848
+ nextSocket.onmessage = event => {
849
+ noteRealtime("terminal");
850
+ const data = JSON.parse(event.data);
851
+ if (data.type === "ready") { terminalReconnectAttempt = 0; $("#terminal-cwd").textContent = `${data.cwd} · ${data.shell}`; terminalStatus(uiLabel("terminalConnected"), "connected"); setRealtimeChannel("terminal", "live"); sendTerminalResize(); return; }
852
+ if (data.type === "output") { terminalEmulator.write(data.data); return; }
853
+ if (data.type === "exit") { terminalShouldReconnect = false; setRealtimeChannel("terminal", "closed"); terminalStatus(`${uiLabel("terminalClosed")}${data.code == null ? "" : ` · ${data.code}`}`, "error"); terminalEmulator.write(`\r\n[${uiLabel("terminalClosed")}]\r\n`); return; }
854
+ if (data.type === "pong") { terminalStatus(uiLabel("terminalConnected"), "connected"); setRealtimeChannel("terminal", "live"); }
855
+ };
856
+ nextSocket.onerror = () => { if (terminalSocket === nextSocket) { terminalStatus(uiLabel("terminalReconnecting"), "reconnecting"); setRealtimeChannel("terminal", "reconnecting"); } };
857
+ nextSocket.onclose = () => {
858
+ if (terminalSocket !== nextSocket) return;
859
+ terminalSocket = null;
860
+ const stillOpen = $("#terminal-window").classList.contains("open") && terminalTaskId === taskId;
861
+ if (!terminalShouldReconnect || !stillOpen) { setRealtimeChannel("terminal", terminalShouldReconnect ? "idle" : "closed"); return; }
862
+ terminalStatus(uiLabel("terminalReconnecting"), "reconnecting"); setRealtimeChannel("terminal", "reconnecting");
863
+ const delay = Math.min(5000, 700 * (2 ** terminalReconnectAttempt));
864
+ terminalReconnectAttempt += 1;
865
+ terminalReconnectTimer = setTimeout(() => connectTerminal(taskId, true), delay);
866
+ };
867
+ }
868
+ function openTerminal() {
869
+ if (!state.selectedId) return toast("请先选择一个会话");
870
+ const windowNode = $("#terminal-window");
871
+ windowNode.classList.add("open"); windowNode.setAttribute("aria-hidden", "false");
872
+ if (!ensureTerminalEmulator()) return;
873
+ requestAnimationFrame(fitTerminal);
874
+ const socketActive = terminalSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(terminalSocket.readyState);
875
+ if (terminalTaskId !== state.selectedId || !socketActive) {
876
+ const preserveHistory = terminalTaskId === state.selectedId && terminalShouldReconnect;
877
+ connectTerminal(state.selectedId, preserveHistory);
878
+ }
879
+ else terminalEmulator.focus();
880
+ if (!terminalResizeObserver) { terminalResizeObserver = new ResizeObserver(sendTerminalResize); terminalResizeObserver.observe($("#terminal-screen")); }
881
+ }
882
+ function hideTerminal() {
883
+ $("#terminal-window").classList.remove("open");
884
+ $("#terminal-window").setAttribute("aria-hidden", "true");
885
+ renderConnectionStatus();
886
+ }
887
+ function closeTerminal() { hideTerminal(); }
888
+ function destroyTerminal() {
889
+ clearTimeout(terminalReconnectTimer); terminalShouldReconnect = false; terminalReconnectAttempt = 0;
890
+ if (terminalSocket) { terminalSocket.onclose = null; try { terminalSocket.send(JSON.stringify({ type: "close" })); } catch (_) {} terminalSocket.close(); terminalSocket = null; }
891
+ terminalTaskId = null;
892
+ hideTerminal();
893
+ setRealtimeChannel("terminal", "idle"); terminalStatus(uiLabel("terminalNotConnected")); $("#terminal-cwd").textContent = uiLabel("terminalNotConnected");
894
+ }