@tt-a1i/openpi 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -40
- package/SETUP.md +22 -6
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +145 -0
- package/extensions/background-terminals/index.ts +30 -2
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +486 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +13 -5
- package/extensions/background-terminals/src/result-delivery.ts +4 -1
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/schedule.ts +7 -1
- package/extensions/file-mutation-display/render.ts +17 -257
- package/extensions/file-search/src/binaries.ts +57 -41
- package/extensions/git-read/index.ts +1 -3
- package/extensions/model-info/index.ts +21 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +7 -2
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +153 -86
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +123 -127
- package/extensions/shared/activity-status.ts +30 -0
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/capability-intent.ts +1 -1
- package/extensions/shared/child-session.ts +437 -21
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/setup-config.ts +73 -33
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +72 -15
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +29 -2
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +270 -59
- package/extensions/subagents/navigation.ts +34 -5
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +375 -66
- package/extensions/subagents/src/domain.ts +5 -0
- package/extensions/subagents/src/manager.ts +34 -2
- package/extensions/subagents/src/prompt.ts +32 -4
- package/extensions/subagents/src/result-artifact.ts +4 -0
- package/extensions/subagents/src/result-delivery.ts +7 -1
- package/extensions/subagents/src/runtime.ts +15 -1
- package/extensions/subagents/src/ui/takeover.ts +73 -257
- package/extensions/subagents/src/ui/transcript.ts +38 -535
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/ui-customization/footer.ts +0 -40
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +1 -1
- package/extensions/web/index.ts +234 -0
- package/extensions/workflows/artifacts.ts +137 -47
- package/extensions/workflows/completion-projection.ts +457 -0
- package/extensions/workflows/coordinator.ts +8 -10
- package/extensions/workflows/dashboard.ts +167 -228
- package/extensions/workflows/handoff.ts +70 -16
- package/extensions/workflows/index.ts +488 -198
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +74 -4
- package/extensions/workflows/navigation.ts +32 -8
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +66 -6
- package/extensions/workflows/replay-safety.ts +42 -21
- package/extensions/workflows/result-delivery.ts +128 -64
- package/extensions/workflows/retention.ts +593 -0
- package/extensions/workflows/runner.ts +388 -279
- package/extensions/workflows/sandbox-child.cjs +25 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +28 -8
- package/skills/subagents/REFERENCE.md +189 -0
- package/skills/subagents/SKILL.md +1 -1
- package/skills/workflows/REFERENCE.md +4 -2
- package/web/adapter/pi-adapter.ts +661 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +789 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +462 -0
- package/web/runtime/pi-runtime.ts +991 -0
- package/web/runtime/types.ts +71 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1398 -0
- package/web/ui/index.html +139 -0
- package/web/ui/styles.css +598 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- package/extensions/subagents/src/backends/stub.ts +0 -303
package/web/ui/app.js
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
const collapsedWorkspacesStorageKey = "openpi.collapsed-workspaces";
|
|
2
|
+
function readCollapsedWorkspaces() {
|
|
3
|
+
try {
|
|
4
|
+
const value = JSON.parse(sessionStorage.getItem(collapsedWorkspacesStorageKey) || "[]");
|
|
5
|
+
return new Set(Array.isArray(value) ? value.filter((path) => typeof path === "string") : []);
|
|
6
|
+
} catch {
|
|
7
|
+
return new Set();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function persistCollapsedWorkspaces(collapsed) {
|
|
12
|
+
try { sessionStorage.setItem(collapsedWorkspacesStorageKey, JSON.stringify([...collapsed])); } catch {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const state = {
|
|
16
|
+
snapshot: null,
|
|
17
|
+
cursor: null,
|
|
18
|
+
selectedPath: null,
|
|
19
|
+
collapsed: readCollapsedWorkspaces(),
|
|
20
|
+
liveMessages: [],
|
|
21
|
+
liveRunning: false,
|
|
22
|
+
promptAdmissionPending: false,
|
|
23
|
+
promptAdmissionToken: null,
|
|
24
|
+
promptAdmissionSequence: 0,
|
|
25
|
+
terminalPromptIds: new Set(),
|
|
26
|
+
sessionEpoch: 0,
|
|
27
|
+
sessionSwitching: false,
|
|
28
|
+
sessionActivation: null,
|
|
29
|
+
completedActivationIds: new Set(),
|
|
30
|
+
snapshotGeneration: 0,
|
|
31
|
+
livePhase: "idle",
|
|
32
|
+
liveRetry: null,
|
|
33
|
+
query: "",
|
|
34
|
+
selectedWorkspace: null,
|
|
35
|
+
language: navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const translations = {
|
|
39
|
+
en: {
|
|
40
|
+
newSession: "New session",
|
|
41
|
+
untitledSession: "New session",
|
|
42
|
+
workspaces: "Workspaces",
|
|
43
|
+
addWorkspace: "Add workspace",
|
|
44
|
+
selectWorkspace: "Select workspace",
|
|
45
|
+
describeTask: "Describe a task",
|
|
46
|
+
chooseWorkspaceHint: "Choose a workspace and describe the work",
|
|
47
|
+
promptStart: "Choose a workspace to begin.",
|
|
48
|
+
promptTask: "Describe what you want to build.",
|
|
49
|
+
promptMessage: "Send a message to the active Web session",
|
|
50
|
+
promptReadonly: "A non-active session cannot receive prompts",
|
|
51
|
+
searchConversations: "Search conversations",
|
|
52
|
+
closeSearch: "Close search",
|
|
53
|
+
searchPlaceholder: "Search conversations...",
|
|
54
|
+
noSessions: "No sessions",
|
|
55
|
+
noMatching: "No matching conversations",
|
|
56
|
+
noConversations: "No conversations yet",
|
|
57
|
+
addWorkspaceMenu: "Add workspace...",
|
|
58
|
+
renameConversation: "Rename conversation",
|
|
59
|
+
archiveConversation: "Archive conversation",
|
|
60
|
+
deleteWorkspace: "Delete workspace",
|
|
61
|
+
cancel: "Cancel",
|
|
62
|
+
workspaceDeleteConfirm: "The folder and conversation records will be kept. Its conversations will move to Ungrouped.",
|
|
63
|
+
ungrouped: "Ungrouped",
|
|
64
|
+
conversationName: "Conversation name",
|
|
65
|
+
conversationOptions: "Conversation options",
|
|
66
|
+
queuedHint: "The message will be queued after the current turn.",
|
|
67
|
+
enterHint: "Enter to send, Shift+Enter for a new line.",
|
|
68
|
+
activeOnlyHint: "Only the active Web session accepts messages.",
|
|
69
|
+
acceptedHint: "Message accepted by OpenPI Web.",
|
|
70
|
+
modelRunning: "Working...",
|
|
71
|
+
modelPreparing: "Preparing task...",
|
|
72
|
+
modelRetrying: "Retrying model request...",
|
|
73
|
+
switchingSession: "Switching session...",
|
|
74
|
+
},
|
|
75
|
+
zh: {
|
|
76
|
+
newSession: "新建会话",
|
|
77
|
+
untitledSession: "新会话",
|
|
78
|
+
workspaces: "工作区",
|
|
79
|
+
addWorkspace: "添加工作区",
|
|
80
|
+
selectWorkspace: "选择工作区",
|
|
81
|
+
describeTask: "描述任务",
|
|
82
|
+
chooseWorkspaceHint: "选择工作区并描述任务",
|
|
83
|
+
promptStart: "选择一个工作区开始",
|
|
84
|
+
promptTask: "描述你想要构建的任务",
|
|
85
|
+
promptMessage: "向当前 Web 会话发送消息",
|
|
86
|
+
promptReadonly: "非当前会话不能接收消息",
|
|
87
|
+
searchConversations: "搜索会话",
|
|
88
|
+
closeSearch: "关闭搜索",
|
|
89
|
+
searchPlaceholder: "搜索会话...",
|
|
90
|
+
noSessions: "暂无会话",
|
|
91
|
+
noMatching: "没有匹配的会话",
|
|
92
|
+
noConversations: "暂无对话",
|
|
93
|
+
addWorkspaceMenu: "添加工作区...",
|
|
94
|
+
renameConversation: "重命名会话",
|
|
95
|
+
archiveConversation: "归档会话",
|
|
96
|
+
deleteWorkspace: "删除工作区",
|
|
97
|
+
cancel: "取消",
|
|
98
|
+
workspaceDeleteConfirm: "文件夹与会话记录会保留,其中的会话会被放到“未分组”;再次打开此目录时将是一个干净的工作区。",
|
|
99
|
+
ungrouped: "未分组",
|
|
100
|
+
conversationName: "会话名称",
|
|
101
|
+
conversationOptions: "会话选项",
|
|
102
|
+
queuedHint: "当前回合结束后将发送消息。",
|
|
103
|
+
enterHint: "按 Enter 发送,Shift+Enter 换行。",
|
|
104
|
+
activeOnlyHint: "只有当前 Web 会话可以接收消息。",
|
|
105
|
+
acceptedHint: "OpenPI Web 已接收消息。",
|
|
106
|
+
modelRunning: "正在运行...",
|
|
107
|
+
modelPreparing: "正在准备任务...",
|
|
108
|
+
modelRetrying: "模型请求重试中...",
|
|
109
|
+
switchingSession: "正在切换会话...",
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const t = (key) => translations[state.language][key] || translations.en[key] || key;
|
|
113
|
+
function applyLanguage() {
|
|
114
|
+
document.documentElement.lang = state.language === "zh" ? "zh-CN" : "en";
|
|
115
|
+
document.querySelectorAll("[data-i18n]").forEach((element) => { element.textContent = t(element.dataset.i18n); });
|
|
116
|
+
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => { element.placeholder = t(element.dataset.i18nPlaceholder); });
|
|
117
|
+
document.querySelectorAll("[data-i18n-aria]").forEach((element) => { element.setAttribute("aria-label", t(element.dataset.i18nAria)); });
|
|
118
|
+
document.querySelectorAll("[data-i18n-title]").forEach((element) => { element.title = t(element.dataset.i18nTitle); });
|
|
119
|
+
const search = $("search");
|
|
120
|
+
if (search) search.placeholder = t("searchPlaceholder");
|
|
121
|
+
renderWorkspacePicker();
|
|
122
|
+
if (state.snapshot) {
|
|
123
|
+
renderWorkspaces();
|
|
124
|
+
renderConversation();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const $ = (id) => document.getElementById(id);
|
|
129
|
+
const tokenStorageKey = "openpi.web.token";
|
|
130
|
+
const fragmentToken = new URLSearchParams(location.hash.slice(1)).get("token");
|
|
131
|
+
let token = fragmentToken;
|
|
132
|
+
if (fragmentToken) {
|
|
133
|
+
try { sessionStorage.setItem(tokenStorageKey, fragmentToken); } catch {}
|
|
134
|
+
history.replaceState(null, "", `${location.pathname}${location.search}`);
|
|
135
|
+
} else {
|
|
136
|
+
try { token = sessionStorage.getItem(tokenStorageKey); } catch {}
|
|
137
|
+
}
|
|
138
|
+
const headers = (json = false) => ({
|
|
139
|
+
Authorization: `Bearer ${token}`,
|
|
140
|
+
...(json ? { "Content-Type": "application/json" } : {}),
|
|
141
|
+
});
|
|
142
|
+
const escapeHtml = (value) =>
|
|
143
|
+
String(value ?? "").replace(
|
|
144
|
+
/[&<>"']/g,
|
|
145
|
+
(char) =>
|
|
146
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
147
|
+
char
|
|
148
|
+
],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const markdownRenderer = (() => {
|
|
152
|
+
const markdown = globalThis.marked;
|
|
153
|
+
if (!markdown?.Renderer) return null;
|
|
154
|
+
const renderer = new markdown.Renderer();
|
|
155
|
+
renderer.html = ({ text }) => escapeHtml(text);
|
|
156
|
+
renderer.link = ({ href, title, tokens }) => {
|
|
157
|
+
const safeHref = safeMarkdownUrl(href);
|
|
158
|
+
const label = renderer.parser.parseInline(tokens);
|
|
159
|
+
if (!safeHref) return label;
|
|
160
|
+
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : "";
|
|
161
|
+
return `<a href="${escapeHtml(safeHref)}"${titleAttribute} target="_blank" rel="noreferrer">${label}</a>`;
|
|
162
|
+
};
|
|
163
|
+
renderer.image = ({ href, title, text, tokens }) => {
|
|
164
|
+
const safeHref = safeMarkdownUrl(href);
|
|
165
|
+
const alt = tokens ? renderer.parser.parseInline(tokens, renderer.parser.textRenderer) : text;
|
|
166
|
+
if (!safeHref) return escapeHtml(alt);
|
|
167
|
+
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : "";
|
|
168
|
+
const label = escapeHtml(alt || "image");
|
|
169
|
+
return `<a href="${escapeHtml(safeHref)}"${titleAttribute} target="_blank" rel="noreferrer">[image: ${label}]</a>`;
|
|
170
|
+
};
|
|
171
|
+
return renderer;
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
function safeMarkdownUrl(value) {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(String(value ?? ""), document.baseURI);
|
|
177
|
+
const allowedProtocols = ["http:", "https:", "mailto:", "tel:"];
|
|
178
|
+
return allowedProtocols.includes(url.protocol) ? url.href : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderMarkdown(value) {
|
|
185
|
+
const content = String(value ?? "");
|
|
186
|
+
if (!markdownRenderer || !globalThis.marked?.parse) return escapeHtml(content);
|
|
187
|
+
try {
|
|
188
|
+
return globalThis.marked.parse(content, {
|
|
189
|
+
renderer: markdownRenderer,
|
|
190
|
+
gfm: true,
|
|
191
|
+
breaks: true,
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
return escapeHtml(content);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function api(path, options = {}) {
|
|
199
|
+
const response = await fetch(path, {
|
|
200
|
+
...options,
|
|
201
|
+
headers: { ...headers(Boolean(options.body)), ...options.headers },
|
|
202
|
+
});
|
|
203
|
+
const body = await response.json().catch(() => ({}));
|
|
204
|
+
if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`);
|
|
205
|
+
return body;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sessionTitle(session) {
|
|
209
|
+
return session.name?.trim() || session.firstMessage?.trim() || t("untitledSession");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function compactSummary(value, limit = 96) {
|
|
213
|
+
const text = String(value ?? "").replace(/\s+/gu, " ").trim();
|
|
214
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function relativeTime(value) {
|
|
218
|
+
const elapsed = Date.now() - new Date(value).getTime();
|
|
219
|
+
if (elapsed < 60_000) return "now";
|
|
220
|
+
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m`;
|
|
221
|
+
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h`;
|
|
222
|
+
return new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function visibleSessions(workspacePath) {
|
|
226
|
+
const query = state.query.toLowerCase();
|
|
227
|
+
return state.snapshot.sessions.filter(
|
|
228
|
+
(session) =>
|
|
229
|
+
session.cwd === workspacePath &&
|
|
230
|
+
!session.ungrouped &&
|
|
231
|
+
!session.archived &&
|
|
232
|
+
(!query ||
|
|
233
|
+
[sessionTitle(session), session.cwd]
|
|
234
|
+
.join(" ")
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
.includes(query)),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function visibleUngroupedSessions() {
|
|
241
|
+
const query = state.query.toLowerCase();
|
|
242
|
+
return state.snapshot.sessions.filter(
|
|
243
|
+
(session) =>
|
|
244
|
+
session.ungrouped &&
|
|
245
|
+
!session.archived &&
|
|
246
|
+
(!query ||
|
|
247
|
+
[sessionTitle(session), session.cwd]
|
|
248
|
+
.join(" ")
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.includes(query)),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ICONS = {
|
|
255
|
+
folder: '<path d="M3.5 7.5a2 2 0 0 1 2-2h5l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"></path>',
|
|
256
|
+
chevronDown: '<path d="m6 9 6 6 6-6"></path>',
|
|
257
|
+
plus: '<path d="M12 5v14M5 12h14"></path>',
|
|
258
|
+
more: '<circle cx="5" cy="12" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle>',
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
function icon(name, className = "icon") {
|
|
262
|
+
return `<svg class="${className}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ""}</svg>`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderWorkspaces() {
|
|
266
|
+
if (!state.snapshot) return;
|
|
267
|
+
const groups = state.snapshot.workspaces
|
|
268
|
+
.map((workspace) => {
|
|
269
|
+
const sessions = visibleSessions(workspace.path);
|
|
270
|
+
if (state.query && sessions.length === 0) return "";
|
|
271
|
+
const collapsed = state.collapsed.has(workspace.path);
|
|
272
|
+
return `<section class="workspace-group ${collapsed ? "collapsed" : ""}" data-workspace="${escapeHtml(workspace.path)}">
|
|
273
|
+
<div class="workspace-button" role="treeitem" aria-expanded="${!collapsed}" title="${escapeHtml(workspace.path)}">
|
|
274
|
+
<button class="workspace-toggle" type="button" aria-label="Toggle ${escapeHtml(workspace.name)}" aria-expanded="${!collapsed}">
|
|
275
|
+
${icon("chevronDown", "workspace-chevron icon")}
|
|
276
|
+
</button>
|
|
277
|
+
<strong>${escapeHtml(workspace.name)}</strong>
|
|
278
|
+
<span class="workspace-row-actions">
|
|
279
|
+
<button class="workspace-action" type="button" data-workspace-action="more" aria-label="Workspace options" title="Workspace options">${icon("more", "icon")}</button>
|
|
280
|
+
<button class="workspace-action" type="button" data-workspace-action="new" aria-label="New session in ${escapeHtml(workspace.name)}" title="New session">${icon("plus", "icon")}</button>
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="workspace-sessions">
|
|
284
|
+
${
|
|
285
|
+
sessions.length
|
|
286
|
+
? sessions
|
|
287
|
+
.map(
|
|
288
|
+
(session) => `<div class="session-row">
|
|
289
|
+
<button class="session ${session.path === state.selectedPath ? "active" : ""}" type="button" role="treeitem" aria-current="${session.path === state.selectedPath ? "page" : "false"}" data-session="${escapeHtml(session.path)}" title="${escapeHtml(sessionTitle(session))}">
|
|
290
|
+
<span class="session-title">${escapeHtml(sessionTitle(session))}</span>
|
|
291
|
+
<span class="session-time">${escapeHtml(relativeTime(session.modified))}</span>
|
|
292
|
+
</button>
|
|
293
|
+
<button class="session-action" type="button" data-session-action="menu" data-session-path="${escapeHtml(session.path)}" aria-label="${escapeHtml(t("conversationOptions"))}" title="${escapeHtml(t("conversationOptions"))}">${icon("more", "icon")}</button>
|
|
294
|
+
</div>`,
|
|
295
|
+
)
|
|
296
|
+
.join("")
|
|
297
|
+
: `<div class="empty">${escapeHtml(t("noConversations"))}</div>`
|
|
298
|
+
}
|
|
299
|
+
</div>
|
|
300
|
+
</section>`;
|
|
301
|
+
})
|
|
302
|
+
.join("");
|
|
303
|
+
const ungrouped = visibleUngroupedSessions();
|
|
304
|
+
const ungroupedGroup = ungrouped.length
|
|
305
|
+
? `<section class="workspace-group ungrouped ${state.collapsed.has("__ungrouped__") ? "collapsed" : ""}" data-workspace="__ungrouped__">
|
|
306
|
+
<div class="workspace-button" role="treeitem" aria-expanded="${!state.collapsed.has("__ungrouped__")}">
|
|
307
|
+
<button class="workspace-toggle" type="button" aria-label="${escapeHtml(t("ungrouped"))}" aria-expanded="${!state.collapsed.has("__ungrouped__")}">${icon("chevronDown", "workspace-chevron icon")}</button>
|
|
308
|
+
<strong>${escapeHtml(t("ungrouped"))}</strong>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="workspace-sessions">${ungrouped.map((session) => `<div class="session-row">
|
|
311
|
+
<button class="session ${session.path === state.selectedPath ? "active" : ""}" type="button" role="treeitem" aria-current="${session.path === state.selectedPath ? "page" : "false"}" data-session="${escapeHtml(session.path)}" title="${escapeHtml(sessionTitle(session))}">
|
|
312
|
+
<span class="session-title">${escapeHtml(sessionTitle(session))}</span><span class="session-time">${escapeHtml(relativeTime(session.modified))}</span>
|
|
313
|
+
</button>
|
|
314
|
+
<button class="session-action" type="button" data-session-action="menu" data-session-path="${escapeHtml(session.path)}" aria-label="${escapeHtml(t("conversationOptions"))}" title="${escapeHtml(t("conversationOptions"))}">${icon("more", "icon")}</button>
|
|
315
|
+
</div>`).join("")}</div>
|
|
316
|
+
</section>`
|
|
317
|
+
: "";
|
|
318
|
+
$("workspaces").innerHTML = groups + ungroupedGroup || `<div class="empty">${escapeHtml(state.query ? t("noMatching") : t("noSessions"))}</div>`;
|
|
319
|
+
|
|
320
|
+
document.querySelectorAll(".workspace-toggle").forEach((button) => {
|
|
321
|
+
button.onclick = (event) => {
|
|
322
|
+
event.stopPropagation();
|
|
323
|
+
toggleWorkspace(button.closest(".workspace-group").dataset.workspace);
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
document.querySelectorAll(".workspace-button").forEach((button) => {
|
|
327
|
+
button.onclick = () => toggleWorkspace(button.closest(".workspace-group").dataset.workspace);
|
|
328
|
+
});
|
|
329
|
+
document.querySelectorAll(".workspace-action").forEach((button) => {
|
|
330
|
+
button.onclick = (event) => {
|
|
331
|
+
event.stopPropagation();
|
|
332
|
+
const path = button.closest(".workspace-group").dataset.workspace;
|
|
333
|
+
if (button.dataset.workspaceAction === "new") void createSession(path);
|
|
334
|
+
else openWorkspaceMenu(path, button);
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
document.querySelectorAll(".session").forEach((button) => {
|
|
338
|
+
button.onclick = () => void selectSession(button.dataset.session);
|
|
339
|
+
});
|
|
340
|
+
document.querySelectorAll(".session-action").forEach((button) => {
|
|
341
|
+
button.onclick = (event) => {
|
|
342
|
+
event.stopPropagation();
|
|
343
|
+
openSessionMenu(button.dataset.sessionPath, button);
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function messageMarkup(entry) {
|
|
349
|
+
const message = entry.message;
|
|
350
|
+
if (!message) return "";
|
|
351
|
+
if (message.role === "user") {
|
|
352
|
+
return `<article class="message-row user">
|
|
353
|
+
<div class="message-content"><div class="message-body">${escapeHtml(message.content)}</div></div>
|
|
354
|
+
</article>`;
|
|
355
|
+
}
|
|
356
|
+
if (message.role === "assistant") {
|
|
357
|
+
const parts = Array.isArray(message.parts) ? message.parts : [];
|
|
358
|
+
const detailItems = parts
|
|
359
|
+
.filter((part) => part.type === "thinking" || part.type === "toolCall")
|
|
360
|
+
.map((part) => {
|
|
361
|
+
const title = part.type === "thinking" ? "Thinking" : `${part.name} · tool call`;
|
|
362
|
+
const body = part.type === "thinking" ? part.text : part.arguments;
|
|
363
|
+
return `<details class="message-details assistant-detail">
|
|
364
|
+
<summary><span class="details-mark" aria-hidden="true"></span><span class="details-title">${escapeHtml(title)}</span></summary>
|
|
365
|
+
<div class="details-body tool-evidence">${escapeHtml(body)}</div>
|
|
366
|
+
</details>`;
|
|
367
|
+
});
|
|
368
|
+
const detailRows = detailItems
|
|
369
|
+
.map(
|
|
370
|
+
(detail) => `<article class="message-row assistant detail-only">
|
|
371
|
+
<div class="message-content">${detail}</div>
|
|
372
|
+
</article>`,
|
|
373
|
+
)
|
|
374
|
+
.join("");
|
|
375
|
+
const content = typeof message.content === "string" ? message.content.trim() : "";
|
|
376
|
+
const contentRow = content
|
|
377
|
+
? `<article class="message-row assistant">
|
|
378
|
+
<div class="message-content"><div class="message-body markdown">${renderMarkdown(content)}</div></div>
|
|
379
|
+
</article>`
|
|
380
|
+
: "";
|
|
381
|
+
return detailRows + contentRow;
|
|
382
|
+
}
|
|
383
|
+
if (message.role === "toolResult") {
|
|
384
|
+
const toolName = message.toolName || "tool";
|
|
385
|
+
const summary = compactSummary(message.content || "completed");
|
|
386
|
+
return `<article class="message-row assistant detail-only">
|
|
387
|
+
<div class="message-content"><details class="message-details tool-details">
|
|
388
|
+
<summary><span class="details-mark" aria-hidden="true"></span><span class="details-title">${escapeHtml(toolName)} · ${escapeHtml(summary)}</span></summary>
|
|
389
|
+
<div class="details-body tool-evidence">${escapeHtml(message.content || "completed")}</div>
|
|
390
|
+
</details></div>
|
|
391
|
+
</article>`;
|
|
392
|
+
}
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function landingMarkup() {
|
|
397
|
+
return `<div class="landing-welcome">
|
|
398
|
+
<div class="landing-brand"><strong>Open<span class="pixel-mark" aria-label="OpenPI"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span></strong></div>
|
|
399
|
+
</div>`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function runtimeActivityMarkup(capabilities) {
|
|
403
|
+
const labels = {
|
|
404
|
+
subagents: "Subagent",
|
|
405
|
+
workflows: "Workflow",
|
|
406
|
+
"background-terminals": "Terminal",
|
|
407
|
+
};
|
|
408
|
+
const rows = Object.entries(capabilities || {}).flatMap(([kind, projection]) => {
|
|
409
|
+
const items = Array.isArray(projection?.items) ? projection.items : [];
|
|
410
|
+
const projected = items.map((item) => {
|
|
411
|
+
const title = item.title || item.name || item.id || item.runId || labels[kind] || kind;
|
|
412
|
+
const status = typeof item.status === "string" ? item.status : "unknown";
|
|
413
|
+
return `<li class="runtime-activity-item" data-status="${escapeHtml(status)}"><strong>${escapeHtml(labels[kind] || kind)}</strong><span>${escapeHtml(title)}</span><small>${escapeHtml(status)}</small></li>`;
|
|
414
|
+
});
|
|
415
|
+
if (Number.isSafeInteger(projection?.omitted) && projection.omitted > 0) {
|
|
416
|
+
projected.push(`<li class="runtime-activity-item omitted"><strong>${escapeHtml(labels[kind] || kind)}</strong><span>+${projection.omitted} omitted</span></li>`);
|
|
417
|
+
}
|
|
418
|
+
return projected;
|
|
419
|
+
});
|
|
420
|
+
return rows.length > 0
|
|
421
|
+
? `<ul class="runtime-activity" aria-label="Runtime activity">${rows.join("")}</ul>`
|
|
422
|
+
: "";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function renderConversation() {
|
|
426
|
+
const snapshot = state.snapshot;
|
|
427
|
+
const selected = snapshot?.selectedSession;
|
|
428
|
+
const shell = document.querySelector(".conversation-shell");
|
|
429
|
+
if (!shell) {
|
|
430
|
+
updateComposer();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const summary = snapshot?.sessions.find((session) => session.path === state.selectedPath);
|
|
434
|
+
if (state.sessionSwitching) {
|
|
435
|
+
shell.classList.remove("landing");
|
|
436
|
+
$("session-header").innerHTML = `<strong>${escapeHtml(summary ? sessionTitle(summary) : t("newSession"))}</strong><small>${escapeHtml(t("switchingSession"))}</small>`;
|
|
437
|
+
$("conversation").innerHTML = `<div class="conversation-running" role="status" aria-live="polite"><span class="conversation-running-dot" aria-hidden="true"></span><span>${escapeHtml(t("switchingSession"))}</span></div>`;
|
|
438
|
+
updateComposer();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const persistedEntries = selected?.entries || [];
|
|
442
|
+
const persistedMessageKeys = new Set(
|
|
443
|
+
persistedEntries.map((entry) => `${entry.message?.role || ""}:${entry.message?.content || ""}`),
|
|
444
|
+
);
|
|
445
|
+
const liveEntries = selected
|
|
446
|
+
? state.liveMessages
|
|
447
|
+
.filter(({ message }) => !persistedMessageKeys.has(`${message.role || ""}:${message.content || ""}`))
|
|
448
|
+
.map(({ message }) => messageMarkup({ type: "message", message }))
|
|
449
|
+
: [];
|
|
450
|
+
const messages = selected
|
|
451
|
+
? [...persistedEntries.map(messageMarkup).filter(Boolean), ...liveEntries].join("")
|
|
452
|
+
: "";
|
|
453
|
+
const landing = !selected || !summary || !messages;
|
|
454
|
+
shell.classList.toggle("landing", landing);
|
|
455
|
+
|
|
456
|
+
if (landing) {
|
|
457
|
+
$("conversation").innerHTML = landingMarkup();
|
|
458
|
+
$("session-header").innerHTML = `<strong>${escapeHtml(t("newSession"))}</strong><small>${escapeHtml(t("chooseWorkspaceHint"))}</small>`;
|
|
459
|
+
updateComposer();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
$("session-header").innerHTML = `<strong>${escapeHtml(sessionTitle(summary))}</strong><small>${escapeHtml(selected.cwd)}</small>`;
|
|
464
|
+
const isCurrentSession = selected.id === snapshot.currentSessionId;
|
|
465
|
+
const isRunning = isCurrentSession && (snapshot.runtime.status === "running" || state.liveRunning);
|
|
466
|
+
const runningLabel = state.liveRetry
|
|
467
|
+
? `${t("modelRetrying")} (${state.liveRetry.attempt}/${state.liveRetry.maxAttempts})`
|
|
468
|
+
: state.livePhase === "preparing" ? t("modelPreparing") : t("modelRunning");
|
|
469
|
+
const activity = isCurrentSession
|
|
470
|
+
? runtimeActivityMarkup(snapshot.runtime.capabilities)
|
|
471
|
+
: "";
|
|
472
|
+
$("conversation").innerHTML = `${messages}${activity}${isRunning ? `<div class="conversation-running" role="status" aria-live="polite"><span class="conversation-running-dot" aria-hidden="true"></span><span>${escapeHtml(runningLabel)}</span></div>` : ""}`;
|
|
473
|
+
$("conversation").scrollTop = $("conversation").scrollHeight;
|
|
474
|
+
updateComposer();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function updateComposer() {
|
|
478
|
+
const selected = state.snapshot?.selectedSession;
|
|
479
|
+
const active = selected?.id === state.snapshot?.currentSessionId;
|
|
480
|
+
const newSessionDraft =
|
|
481
|
+
Boolean(state.selectedWorkspace) &&
|
|
482
|
+
!selected &&
|
|
483
|
+
!state.snapshot?.currentSessionId;
|
|
484
|
+
const canCompose = active || newSessionDraft;
|
|
485
|
+
$("prompt-input").disabled =
|
|
486
|
+
state.sessionSwitching || (!canCompose && Boolean(state.selectedWorkspace));
|
|
487
|
+
$("send-prompt").disabled =
|
|
488
|
+
state.sessionSwitching ||
|
|
489
|
+
!canCompose ||
|
|
490
|
+
!state.selectedWorkspace ||
|
|
491
|
+
state.promptAdmissionPending;
|
|
492
|
+
const modelPicker = $("model-picker");
|
|
493
|
+
const modelPickerValue = $("model-picker-value");
|
|
494
|
+
const modelMenu = $("model-menu");
|
|
495
|
+
const models = state.snapshot?.models || [];
|
|
496
|
+
if (modelPicker && models.length > 0) {
|
|
497
|
+
const current = models.find((model) => model.current) || models[0];
|
|
498
|
+
if (modelPickerValue) modelPickerValue.textContent = current.label;
|
|
499
|
+
if (modelMenu) {
|
|
500
|
+
modelMenu.innerHTML = models
|
|
501
|
+
.map((model) => `<button type="button" role="option" data-model="${escapeHtml(`${model.provider}/${model.id}`)}" aria-selected="${model.current ? "true" : "false"}"><span>${escapeHtml(model.label)}</span><span class="model-menu-check">✓</span></button>`)
|
|
502
|
+
.join("");
|
|
503
|
+
modelMenu.querySelectorAll("[data-model]").forEach((option) => {
|
|
504
|
+
option.onclick = () => {
|
|
505
|
+
modelMenu.hidden = true;
|
|
506
|
+
modelPicker.setAttribute("aria-expanded", "false");
|
|
507
|
+
void selectModel(option.dataset.model);
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
modelPicker.disabled =
|
|
512
|
+
state.sessionSwitching ||
|
|
513
|
+
!active ||
|
|
514
|
+
!state.selectedWorkspace ||
|
|
515
|
+
state.liveRunning;
|
|
516
|
+
} else if (modelPicker) {
|
|
517
|
+
if (modelPickerValue) modelPickerValue.textContent = "No models available";
|
|
518
|
+
if (modelMenu) modelMenu.innerHTML = "";
|
|
519
|
+
modelPicker.disabled = true;
|
|
520
|
+
}
|
|
521
|
+
const shell = document.querySelector(".conversation-shell");
|
|
522
|
+
const landing = !shell || shell.classList.contains("landing");
|
|
523
|
+
$("prompt-input").placeholder = !state.selectedWorkspace
|
|
524
|
+
? t("promptStart")
|
|
525
|
+
: landing
|
|
526
|
+
? t("promptTask")
|
|
527
|
+
: active
|
|
528
|
+
? t("promptMessage")
|
|
529
|
+
: t("promptReadonly");
|
|
530
|
+
$("composer-hint").textContent = canCompose
|
|
531
|
+
? state.snapshot.runtime.status === "running" || state.liveRunning
|
|
532
|
+
? t("queuedHint")
|
|
533
|
+
: t("enterHint")
|
|
534
|
+
: t("activeOnlyHint");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function selectModel(value) {
|
|
538
|
+
const [provider, ...idParts] = value.split("/");
|
|
539
|
+
const modelId = idParts.join("/");
|
|
540
|
+
if (!provider || !modelId) return;
|
|
541
|
+
const epoch = state.sessionEpoch;
|
|
542
|
+
const sessionId = state.snapshot?.selectedSession?.id;
|
|
543
|
+
if (!sessionId || state.sessionSwitching) return;
|
|
544
|
+
const picker = $("model-picker");
|
|
545
|
+
if (picker) picker.disabled = true;
|
|
546
|
+
try {
|
|
547
|
+
await api("/api/model", {
|
|
548
|
+
method: "POST",
|
|
549
|
+
body: JSON.stringify({ provider, modelId, sessionId }),
|
|
550
|
+
});
|
|
551
|
+
if (
|
|
552
|
+
epoch !== state.sessionEpoch ||
|
|
553
|
+
sessionId !== state.snapshot?.selectedSession?.id
|
|
554
|
+
) return;
|
|
555
|
+
await refreshSnapshot({ epoch });
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (
|
|
558
|
+
epoch !== state.sessionEpoch ||
|
|
559
|
+
sessionId !== state.snapshot?.selectedSession?.id
|
|
560
|
+
) return;
|
|
561
|
+
showNotice(error.message);
|
|
562
|
+
await refreshSnapshot({ epoch });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function refreshSnapshot({
|
|
567
|
+
resetCursor = false,
|
|
568
|
+
epoch = state.sessionEpoch,
|
|
569
|
+
canonicalRetry = false,
|
|
570
|
+
} = {}) {
|
|
571
|
+
const generation = ++state.snapshotGeneration;
|
|
572
|
+
try {
|
|
573
|
+
const requestedPath = state.selectedPath;
|
|
574
|
+
const suffix = requestedPath
|
|
575
|
+
? `?path=${encodeURIComponent(requestedPath)}`
|
|
576
|
+
: "";
|
|
577
|
+
const snapshot = await api(`/api/snapshot${suffix}`);
|
|
578
|
+
if (
|
|
579
|
+
epoch !== state.sessionEpoch ||
|
|
580
|
+
generation !== state.snapshotGeneration
|
|
581
|
+
) return false;
|
|
582
|
+
const hasCurrentSession = typeof snapshot.currentSessionId === "string";
|
|
583
|
+
const current = hasCurrentSession
|
|
584
|
+
? snapshot.sessions.find(
|
|
585
|
+
(session) => session.id === snapshot.currentSessionId,
|
|
586
|
+
)
|
|
587
|
+
: undefined;
|
|
588
|
+
const selectedIsCurrent = hasCurrentSession
|
|
589
|
+
? snapshot.selectedSession?.id === snapshot.currentSessionId
|
|
590
|
+
: snapshot.selectedSession === undefined;
|
|
591
|
+
const requestedExists =
|
|
592
|
+
!requestedPath ||
|
|
593
|
+
snapshot.sessions.some((session) => session.path === requestedPath);
|
|
594
|
+
const requestedMatches =
|
|
595
|
+
!requestedPath || snapshot.selectedSession?.path === requestedPath;
|
|
596
|
+
if (!requestedExists || !requestedMatches || !selectedIsCurrent) {
|
|
597
|
+
state.selectedPath = null;
|
|
598
|
+
if (!canonicalRetry) {
|
|
599
|
+
return refreshSnapshot({
|
|
600
|
+
resetCursor,
|
|
601
|
+
epoch,
|
|
602
|
+
canonicalRetry: true,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
state.snapshot = snapshot;
|
|
608
|
+
if (
|
|
609
|
+
state.snapshot.runtime.status !== "running" &&
|
|
610
|
+
!state.promptAdmissionPending
|
|
611
|
+
) {
|
|
612
|
+
state.liveRunning = false;
|
|
613
|
+
state.livePhase = "idle";
|
|
614
|
+
state.liveRetry = null;
|
|
615
|
+
}
|
|
616
|
+
if (resetCursor) resetLiveState();
|
|
617
|
+
state.cursor = resetCursor || state.cursor === null
|
|
618
|
+
? state.snapshot.cursor
|
|
619
|
+
: Math.max(state.cursor, state.snapshot.cursor);
|
|
620
|
+
const availableWorkspaces = state.snapshot.workspaces;
|
|
621
|
+
const selectedSessionWorkspace = state.snapshot.selectedSession?.cwd;
|
|
622
|
+
const activeWorkspace = availableWorkspaces.find((workspace) => workspace.current)?.path;
|
|
623
|
+
const retainedWorkspace = availableWorkspaces.some(
|
|
624
|
+
(workspace) => workspace.path === state.selectedWorkspace,
|
|
625
|
+
)
|
|
626
|
+
? state.selectedWorkspace
|
|
627
|
+
: undefined;
|
|
628
|
+
const readyWorkspace = availableWorkspaces.some((workspace) => workspace.path === selectedSessionWorkspace)
|
|
629
|
+
? selectedSessionWorkspace
|
|
630
|
+
: activeWorkspace ?? retainedWorkspace;
|
|
631
|
+
setWorkspaceReady(readyWorkspace);
|
|
632
|
+
state.selectedPath = current?.path || snapshot.selectedSession?.path || null;
|
|
633
|
+
renderWorkspaces();
|
|
634
|
+
renderConversation();
|
|
635
|
+
return true;
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (
|
|
638
|
+
epoch !== state.sessionEpoch ||
|
|
639
|
+
generation !== state.snapshotGeneration
|
|
640
|
+
) return false;
|
|
641
|
+
$("connection-state").textContent = "Unavailable";
|
|
642
|
+
$("connection-state").classList.add("reconnecting");
|
|
643
|
+
$("composer-hint").textContent = error.message;
|
|
644
|
+
$("composer-hint").classList.add("error");
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let sessionSelectionTail = Promise.resolve();
|
|
650
|
+
async function selectSession(path) {
|
|
651
|
+
if (!path) return;
|
|
652
|
+
const epoch = ++state.sessionEpoch;
|
|
653
|
+
state.sessionSwitching = true;
|
|
654
|
+
state.selectedPath = path;
|
|
655
|
+
state.promptAdmissionPending = false;
|
|
656
|
+
state.promptAdmissionToken = null;
|
|
657
|
+
resetLiveState();
|
|
658
|
+
document.body.classList.remove("sidebar-open");
|
|
659
|
+
renderWorkspaces();
|
|
660
|
+
renderConversation();
|
|
661
|
+
const selection = sessionSelectionTail.then(async () => {
|
|
662
|
+
if (epoch !== state.sessionEpoch) return;
|
|
663
|
+
state.sessionActivation = { epoch, kind: "select", expectedPath: path };
|
|
664
|
+
try {
|
|
665
|
+
await api("/api/sessions/select", {
|
|
666
|
+
method: "POST",
|
|
667
|
+
body: JSON.stringify({ path }),
|
|
668
|
+
});
|
|
669
|
+
if (epoch !== state.sessionEpoch) return;
|
|
670
|
+
if (!(await refreshSnapshot({ epoch }))) state.selectedPath = null;
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (epoch !== state.sessionEpoch) return;
|
|
673
|
+
// Re-read Pi's canonical current session instead of rolling back to a
|
|
674
|
+
// possibly optimistic path from an older queued selection.
|
|
675
|
+
state.selectedPath = null;
|
|
676
|
+
showNotice(error.message);
|
|
677
|
+
await refreshSnapshot({ epoch });
|
|
678
|
+
} finally {
|
|
679
|
+
if (state.sessionActivation?.epoch === epoch) state.sessionActivation = null;
|
|
680
|
+
if (epoch === state.sessionEpoch) {
|
|
681
|
+
state.sessionSwitching = false;
|
|
682
|
+
renderWorkspaces();
|
|
683
|
+
renderConversation();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
sessionSelectionTail = selection.catch(() => undefined);
|
|
688
|
+
await selection;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let refreshTimer = null;
|
|
692
|
+
let refreshInFlight = false;
|
|
693
|
+
let refreshPending = false;
|
|
694
|
+
|
|
695
|
+
function scheduleSnapshotRefresh(delay = 160) {
|
|
696
|
+
if (refreshInFlight) {
|
|
697
|
+
refreshPending = true;
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (refreshTimer !== null) return;
|
|
701
|
+
refreshTimer = window.setTimeout(async () => {
|
|
702
|
+
refreshTimer = null;
|
|
703
|
+
refreshInFlight = true;
|
|
704
|
+
try {
|
|
705
|
+
await refreshSnapshot();
|
|
706
|
+
} finally {
|
|
707
|
+
refreshInFlight = false;
|
|
708
|
+
if (refreshPending) {
|
|
709
|
+
refreshPending = false;
|
|
710
|
+
scheduleSnapshotRefresh();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}, delay);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function sendPrompt() {
|
|
717
|
+
if (!state.selectedWorkspace) {
|
|
718
|
+
await chooseWorkspace();
|
|
719
|
+
}
|
|
720
|
+
const content = $("prompt-input").value.trim();
|
|
721
|
+
if (
|
|
722
|
+
!content ||
|
|
723
|
+
!state.selectedWorkspace ||
|
|
724
|
+
state.sessionSwitching ||
|
|
725
|
+
state.promptAdmissionPending
|
|
726
|
+
) return;
|
|
727
|
+
if (!state.snapshot?.selectedSession?.id) {
|
|
728
|
+
await createSession(state.selectedWorkspace);
|
|
729
|
+
}
|
|
730
|
+
const sessionId = state.snapshot?.selectedSession?.id;
|
|
731
|
+
if (!sessionId || state.sessionSwitching || state.promptAdmissionPending) return;
|
|
732
|
+
const epoch = state.sessionEpoch;
|
|
733
|
+
const admissionToken = ++state.promptAdmissionSequence;
|
|
734
|
+
const optimisticKey = `optimistic-${Date.now()}`;
|
|
735
|
+
state.liveMessages = [
|
|
736
|
+
...state.liveMessages,
|
|
737
|
+
{ key: optimisticKey, message: { role: "user", content } },
|
|
738
|
+
].slice(-8);
|
|
739
|
+
state.promptAdmissionPending = true;
|
|
740
|
+
state.promptAdmissionToken = admissionToken;
|
|
741
|
+
renderConversation();
|
|
742
|
+
$("composer-hint").classList.remove("error");
|
|
743
|
+
try {
|
|
744
|
+
const receipt = await api("/api/prompt", {
|
|
745
|
+
method: "POST",
|
|
746
|
+
body: JSON.stringify({ sessionId, content }),
|
|
747
|
+
});
|
|
748
|
+
if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return;
|
|
749
|
+
const alreadySettled = state.terminalPromptIds.has(receipt.id);
|
|
750
|
+
state.liveRunning = !alreadySettled;
|
|
751
|
+
state.livePhase = alreadySettled ? "idle" : "preparing";
|
|
752
|
+
$("prompt-input").value = "";
|
|
753
|
+
resizePrompt();
|
|
754
|
+
$("composer-hint").textContent = t("acceptedHint");
|
|
755
|
+
scheduleSnapshotRefresh(120);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return;
|
|
758
|
+
state.liveRunning = false;
|
|
759
|
+
state.livePhase = "idle";
|
|
760
|
+
state.liveRetry = null;
|
|
761
|
+
state.liveMessages = state.liveMessages.filter(
|
|
762
|
+
(entry) => entry.key !== optimisticKey,
|
|
763
|
+
);
|
|
764
|
+
$("composer-hint").textContent = error.message;
|
|
765
|
+
$("composer-hint").classList.add("error");
|
|
766
|
+
} finally {
|
|
767
|
+
if (epoch === state.sessionEpoch && state.promptAdmissionToken === admissionToken) {
|
|
768
|
+
state.promptAdmissionPending = false;
|
|
769
|
+
state.promptAdmissionToken = null;
|
|
770
|
+
renderConversation();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function resizePrompt() {
|
|
776
|
+
const input = $("prompt-input");
|
|
777
|
+
const maxHeight = 220;
|
|
778
|
+
input.style.height = "auto";
|
|
779
|
+
input.style.height = `${Math.min(input.scrollHeight, maxHeight)}px`;
|
|
780
|
+
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function workspaceName(path) {
|
|
784
|
+
return path.split(/[\\/]/).filter(Boolean).pop() || path;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function setWorkspaceReady(path) {
|
|
788
|
+
state.selectedWorkspace = path || null;
|
|
789
|
+
const composer = $("composer");
|
|
790
|
+
const input = $("prompt-input");
|
|
791
|
+
composer?.classList.toggle("dormant", !state.selectedWorkspace);
|
|
792
|
+
if (input) input.readOnly = !state.selectedWorkspace;
|
|
793
|
+
$("selected-workspace").textContent = state.selectedWorkspace
|
|
794
|
+
? workspaceName(state.selectedWorkspace)
|
|
795
|
+
: t("selectWorkspace");
|
|
796
|
+
renderWorkspacePicker();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function renderWorkspacePicker() {
|
|
800
|
+
const menu = $("workspace-picker-menu");
|
|
801
|
+
const picker = $("select-workspace");
|
|
802
|
+
if (!menu || !picker) return;
|
|
803
|
+
const workspaces = state.snapshot?.workspaces || [];
|
|
804
|
+
if (workspaces.length === 0) {
|
|
805
|
+
menu.hidden = true;
|
|
806
|
+
picker.setAttribute("aria-expanded", "false");
|
|
807
|
+
$("selected-workspace").textContent = t("selectWorkspace");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
menu.innerHTML = `${workspaces.map((workspace) => `<button type="button" role="option" data-workspace-path="${escapeHtml(workspace.path)}" aria-selected="${workspace.path === state.selectedWorkspace ? "true" : "false"}">${icon("folder", "workspace-menu-icon icon")}<span>${escapeHtml(workspace.name)}</span><span class="workspace-menu-check">✓</span></button>`).join("")}<button type="button" class="workspace-menu-add" data-workspace-add="true">${icon("plus", "workspace-menu-icon icon")}<span>${escapeHtml(t("addWorkspaceMenu"))}</span></button>`;
|
|
811
|
+
picker.setAttribute("aria-expanded", String(!menu.hidden));
|
|
812
|
+
menu.querySelectorAll("[data-workspace-path]").forEach((button) => {
|
|
813
|
+
button.onclick = () => {
|
|
814
|
+
const path = button.dataset.workspacePath;
|
|
815
|
+
menu.hidden = true;
|
|
816
|
+
picker.setAttribute("aria-expanded", "false");
|
|
817
|
+
if (!path) return;
|
|
818
|
+
setWorkspaceReady(path);
|
|
819
|
+
void refreshSnapshot();
|
|
820
|
+
};
|
|
821
|
+
});
|
|
822
|
+
menu.querySelector("[data-workspace-add]")?.addEventListener("click", () => {
|
|
823
|
+
menu.hidden = true;
|
|
824
|
+
picker.setAttribute("aria-expanded", "false");
|
|
825
|
+
void chooseWorkspace();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function toggleWorkspace(path) {
|
|
830
|
+
if (state.collapsed.has(path)) state.collapsed.delete(path);
|
|
831
|
+
else state.collapsed.add(path);
|
|
832
|
+
persistCollapsedWorkspaces(state.collapsed);
|
|
833
|
+
renderWorkspaces();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function closeSearch({ restoreFocus = true } = {}) {
|
|
837
|
+
const search = $("search");
|
|
838
|
+
const heading = search?.closest(".workspace-heading");
|
|
839
|
+
if (!search || !heading) return;
|
|
840
|
+
heading.classList.remove("is-searching");
|
|
841
|
+
search.value = "";
|
|
842
|
+
state.query = "";
|
|
843
|
+
renderWorkspaces();
|
|
844
|
+
if (restoreFocus) $("search-toggle")?.focus();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function createSession(workspacePath) {
|
|
848
|
+
if (!workspacePath) return;
|
|
849
|
+
const epoch = ++state.sessionEpoch;
|
|
850
|
+
const commandId = globalThis.crypto?.randomUUID?.() ||
|
|
851
|
+
`web-create-${Date.now()}-${epoch}`;
|
|
852
|
+
state.sessionSwitching = true;
|
|
853
|
+
state.selectedPath = null;
|
|
854
|
+
state.promptAdmissionPending = false;
|
|
855
|
+
state.promptAdmissionToken = null;
|
|
856
|
+
resetLiveState();
|
|
857
|
+
document.body.classList.remove("sidebar-open");
|
|
858
|
+
renderWorkspaces();
|
|
859
|
+
renderConversation();
|
|
860
|
+
const creation = sessionSelectionTail.then(async () => {
|
|
861
|
+
if (epoch !== state.sessionEpoch) return;
|
|
862
|
+
state.sessionActivation = {
|
|
863
|
+
epoch,
|
|
864
|
+
kind: "create",
|
|
865
|
+
commandId,
|
|
866
|
+
expectedPath: null,
|
|
867
|
+
observedPath: null,
|
|
868
|
+
};
|
|
869
|
+
try {
|
|
870
|
+
const result = await api("/api/sessions", {
|
|
871
|
+
method: "POST",
|
|
872
|
+
body: JSON.stringify({ workspacePath, commandId }),
|
|
873
|
+
});
|
|
874
|
+
if (epoch !== state.sessionEpoch) return;
|
|
875
|
+
state.selectedPath = null;
|
|
876
|
+
await refreshSnapshot({ epoch });
|
|
877
|
+
if (!result.cancelled && epoch === state.sessionEpoch) {
|
|
878
|
+
$("prompt-input")?.focus();
|
|
879
|
+
}
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if (epoch !== state.sessionEpoch) return;
|
|
882
|
+
state.selectedPath = null;
|
|
883
|
+
showNotice(error.message);
|
|
884
|
+
await refreshSnapshot({ epoch });
|
|
885
|
+
} finally {
|
|
886
|
+
rememberCompletedActivation(commandId);
|
|
887
|
+
if (state.sessionActivation?.epoch === epoch) state.sessionActivation = null;
|
|
888
|
+
if (epoch === state.sessionEpoch) {
|
|
889
|
+
state.sessionSwitching = false;
|
|
890
|
+
renderWorkspaces();
|
|
891
|
+
renderConversation();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
sessionSelectionTail = creation.catch(() => undefined);
|
|
896
|
+
await creation;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function showNotice(message) {
|
|
900
|
+
$("composer-hint").textContent = message;
|
|
901
|
+
$("composer-hint").classList.add("error");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function openWorkspaceMenu(path, anchor) {
|
|
905
|
+
const menu = $("workspace-menu");
|
|
906
|
+
menu.dataset.workspace = path;
|
|
907
|
+
const rect = anchor.getBoundingClientRect();
|
|
908
|
+
menu.style.left = `${Math.max(8, rect.right - 178)}px`;
|
|
909
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
910
|
+
menu.show();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function openSessionMenu(path, anchor) {
|
|
914
|
+
const menu = $("session-menu");
|
|
915
|
+
if (!menu || !path) return;
|
|
916
|
+
menu.dataset.session = path;
|
|
917
|
+
const rect = anchor.getBoundingClientRect();
|
|
918
|
+
menu.style.left = `${Math.max(8, rect.right - 178)}px`;
|
|
919
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
920
|
+
menu.show();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function openWorkspaceRename(path) {
|
|
924
|
+
const workspace = state.snapshot.workspaces.find((item) => item.path === path);
|
|
925
|
+
const dialog = $("workspace-rename-dialog");
|
|
926
|
+
$("workspace-name-input").value = workspace?.name || workspaceName(path);
|
|
927
|
+
dialog.dataset.workspace = path;
|
|
928
|
+
$("workspace-menu").close();
|
|
929
|
+
dialog.showModal();
|
|
930
|
+
$("workspace-name-input").focus();
|
|
931
|
+
$("workspace-name-input").select();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function removeWorkspace(path) {
|
|
935
|
+
$("workspace-menu").close();
|
|
936
|
+
try {
|
|
937
|
+
await api(`/api/workspaces?path=${encodeURIComponent(path)}`, { method: "DELETE" });
|
|
938
|
+
if (state.selectedWorkspace === path) setWorkspaceReady(null);
|
|
939
|
+
state.selectedPath = null;
|
|
940
|
+
await refreshSnapshot();
|
|
941
|
+
} catch (error) {
|
|
942
|
+
showNotice(error.message);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function requestRemoveWorkspace(path) {
|
|
947
|
+
const workspace = state.snapshot?.workspaces.find((item) => item.path === path);
|
|
948
|
+
if (!workspace) return;
|
|
949
|
+
$("workspace-menu").close();
|
|
950
|
+
const dialog = $("workspace-delete-dialog");
|
|
951
|
+
const message = $("workspace-delete-message");
|
|
952
|
+
if (!dialog || !message) return;
|
|
953
|
+
dialog.dataset.workspace = path;
|
|
954
|
+
message.textContent = `${workspace.name}:${t("workspaceDeleteConfirm")}`;
|
|
955
|
+
dialog.showModal();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function openSessionRename(path) {
|
|
959
|
+
const session = state.snapshot?.sessions.find((item) => item.path === path);
|
|
960
|
+
const dialog = $("session-rename-dialog");
|
|
961
|
+
if (!dialog) return;
|
|
962
|
+
$("session-name-input").value = sessionTitle(session || {});
|
|
963
|
+
dialog.dataset.session = path;
|
|
964
|
+
$("session-menu")?.close();
|
|
965
|
+
dialog.showModal();
|
|
966
|
+
$("session-name-input").focus();
|
|
967
|
+
$("session-name-input").select();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function archiveSession(path) {
|
|
971
|
+
if (!path) return;
|
|
972
|
+
$("session-menu")?.close();
|
|
973
|
+
try {
|
|
974
|
+
await api(`/api/sessions/archive?path=${encodeURIComponent(path)}`, { method: "POST" });
|
|
975
|
+
await refreshSnapshot();
|
|
976
|
+
} catch (error) {
|
|
977
|
+
showNotice(error.message);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function chooseWorkspace() {
|
|
982
|
+
try {
|
|
983
|
+
const result = await api("/api/workspaces/select", { method: "POST" });
|
|
984
|
+
if (result.cancelled || !result.path) return;
|
|
985
|
+
setWorkspaceReady(result.path);
|
|
986
|
+
await refreshSnapshot();
|
|
987
|
+
} catch (error) {
|
|
988
|
+
const selectedWorkspace = $("selected-workspace");
|
|
989
|
+
if (selectedWorkspace) selectedWorkspace.textContent = error.message;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function applyRuntimeEvent(event) {
|
|
994
|
+
const eventSessionId = event.detail?.sessionId;
|
|
995
|
+
const sessionTransition =
|
|
996
|
+
event.type === "session_start" ||
|
|
997
|
+
event.type === "session_switched" ||
|
|
998
|
+
event.type === "session_created";
|
|
999
|
+
if (state.sessionSwitching && !sessionTransition) {
|
|
1000
|
+
scheduleSnapshotRefresh();
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (
|
|
1004
|
+
typeof eventSessionId === "string" &&
|
|
1005
|
+
eventSessionId !== state.snapshot?.currentSessionId &&
|
|
1006
|
+
event.type !== "session_start" &&
|
|
1007
|
+
event.type !== "session_switched" &&
|
|
1008
|
+
event.type !== "session_created"
|
|
1009
|
+
) {
|
|
1010
|
+
scheduleSnapshotRefresh();
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (sessionTransition) {
|
|
1014
|
+
const activation = state.sessionActivation;
|
|
1015
|
+
const eventCommandId = event.detail?.commandId;
|
|
1016
|
+
if (
|
|
1017
|
+
typeof eventCommandId === "string" &&
|
|
1018
|
+
state.completedActivationIds.has(eventCommandId)
|
|
1019
|
+
) {
|
|
1020
|
+
scheduleSnapshotRefresh();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const eventPath = event.detail?.sessionPath;
|
|
1024
|
+
const knownExistingPath = state.snapshot?.sessions.some(
|
|
1025
|
+
(session) => session.path === eventPath,
|
|
1026
|
+
);
|
|
1027
|
+
let belongsToActivation = false;
|
|
1028
|
+
if (activation?.kind === "select") {
|
|
1029
|
+
belongsToActivation = activation.expectedPath === eventPath;
|
|
1030
|
+
} else if (
|
|
1031
|
+
activation?.kind === "create" &&
|
|
1032
|
+
activation.commandId === eventCommandId &&
|
|
1033
|
+
event.type === "session_switched" &&
|
|
1034
|
+
typeof eventPath === "string" &&
|
|
1035
|
+
!knownExistingPath
|
|
1036
|
+
) {
|
|
1037
|
+
activation.observedPath = eventPath;
|
|
1038
|
+
belongsToActivation = true;
|
|
1039
|
+
} else if (
|
|
1040
|
+
activation?.kind === "create" &&
|
|
1041
|
+
activation.commandId === eventCommandId &&
|
|
1042
|
+
event.type === "session_created" &&
|
|
1043
|
+
typeof activation.observedPath === "string"
|
|
1044
|
+
) {
|
|
1045
|
+
belongsToActivation = true;
|
|
1046
|
+
}
|
|
1047
|
+
if (belongsToActivation && activation.epoch !== state.sessionEpoch) return;
|
|
1048
|
+
if (!belongsToActivation) {
|
|
1049
|
+
state.sessionEpoch += 1;
|
|
1050
|
+
state.promptAdmissionPending = false;
|
|
1051
|
+
state.promptAdmissionToken = null;
|
|
1052
|
+
state.sessionSwitching = true;
|
|
1053
|
+
state.selectedPath = typeof eventPath === "string" ? eventPath : null;
|
|
1054
|
+
const epoch = state.sessionEpoch;
|
|
1055
|
+
void refreshSnapshot({ epoch }).then((refreshed) => {
|
|
1056
|
+
if (epoch !== state.sessionEpoch) return;
|
|
1057
|
+
if (!refreshed) state.selectedPath = null;
|
|
1058
|
+
state.sessionSwitching = false;
|
|
1059
|
+
renderWorkspaces();
|
|
1060
|
+
renderConversation();
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
resetLiveState();
|
|
1064
|
+
renderWorkspaces();
|
|
1065
|
+
renderConversation();
|
|
1066
|
+
scheduleSnapshotRefresh();
|
|
1067
|
+
} else if (event.type === "prompt_accepted") {
|
|
1068
|
+
const alreadySettled = state.terminalPromptIds.has(event.detail?.commandId);
|
|
1069
|
+
state.liveRunning = !alreadySettled;
|
|
1070
|
+
state.livePhase = alreadySettled ? "idle" : "preparing";
|
|
1071
|
+
state.liveRetry = null;
|
|
1072
|
+
renderConversation();
|
|
1073
|
+
} else if (event.type === "agent_start") {
|
|
1074
|
+
state.liveRunning = true;
|
|
1075
|
+
state.livePhase = "running";
|
|
1076
|
+
state.liveRetry = null;
|
|
1077
|
+
renderConversation();
|
|
1078
|
+
} else if (event.type === "agent_settled" || event.type === "prompt_settled") {
|
|
1079
|
+
if (event.type === "prompt_settled") rememberTerminalPrompt(event.detail?.commandId);
|
|
1080
|
+
state.liveRunning = false;
|
|
1081
|
+
state.livePhase = "idle";
|
|
1082
|
+
state.liveRetry = null;
|
|
1083
|
+
renderConversation();
|
|
1084
|
+
} else if (event.detail?.message) {
|
|
1085
|
+
if (event.detail.message.role === "user") {
|
|
1086
|
+
state.liveMessages = state.liveMessages.filter(
|
|
1087
|
+
(entry) =>
|
|
1088
|
+
!entry.key.startsWith("optimistic-") ||
|
|
1089
|
+
entry.message.content !== event.detail.message.content,
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
const key = event.detail.messageKey || `${event.detail.message.role || "message"}-${event.sequence}`;
|
|
1093
|
+
const index = state.liveMessages.findIndex((entry) => entry.key === key);
|
|
1094
|
+
const live = { key, message: event.detail.message };
|
|
1095
|
+
if (index >= 0) state.liveMessages[index] = live;
|
|
1096
|
+
else state.liveMessages = [...state.liveMessages, live].slice(-8);
|
|
1097
|
+
renderConversation();
|
|
1098
|
+
}
|
|
1099
|
+
if (
|
|
1100
|
+
[
|
|
1101
|
+
"agent_start",
|
|
1102
|
+
"agent_settled",
|
|
1103
|
+
"prompt_settled",
|
|
1104
|
+
"message_end",
|
|
1105
|
+
"tool_execution_end",
|
|
1106
|
+
"session_start",
|
|
1107
|
+
"session_switched",
|
|
1108
|
+
"session_progress",
|
|
1109
|
+
"prompt_failed",
|
|
1110
|
+
"model_select",
|
|
1111
|
+
"workspace_imported",
|
|
1112
|
+
"workspace_removed",
|
|
1113
|
+
"workspace_renamed",
|
|
1114
|
+
"session_renamed",
|
|
1115
|
+
"session_archived",
|
|
1116
|
+
"session_created",
|
|
1117
|
+
"prompt_accepted",
|
|
1118
|
+
"runtime_changed",
|
|
1119
|
+
].includes(event.type)
|
|
1120
|
+
) {
|
|
1121
|
+
scheduleSnapshotRefresh();
|
|
1122
|
+
}
|
|
1123
|
+
if (event.type === "prompt_failed") {
|
|
1124
|
+
rememberTerminalPrompt(event.detail?.commandId);
|
|
1125
|
+
state.liveRunning = false;
|
|
1126
|
+
state.livePhase = "idle";
|
|
1127
|
+
state.liveRetry = null;
|
|
1128
|
+
state.liveMessages = state.liveMessages.filter(
|
|
1129
|
+
(entry) => !entry.key.startsWith("optimistic-"),
|
|
1130
|
+
);
|
|
1131
|
+
showNotice(event.detail?.error || "Prompt failed");
|
|
1132
|
+
renderConversation();
|
|
1133
|
+
}
|
|
1134
|
+
if (event.type === "auto_retry_start") {
|
|
1135
|
+
state.liveRunning = true;
|
|
1136
|
+
state.livePhase = "running";
|
|
1137
|
+
state.liveRetry = {
|
|
1138
|
+
attempt: event.detail?.attempt || 0,
|
|
1139
|
+
maxAttempts: event.detail?.maxAttempts || 0,
|
|
1140
|
+
};
|
|
1141
|
+
renderConversation();
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function rememberTerminalPrompt(commandId) {
|
|
1146
|
+
if (typeof commandId !== "string") return;
|
|
1147
|
+
state.terminalPromptIds.add(commandId);
|
|
1148
|
+
while (state.terminalPromptIds.size > 32) {
|
|
1149
|
+
state.terminalPromptIds.delete(state.terminalPromptIds.values().next().value);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function rememberCompletedActivation(commandId) {
|
|
1154
|
+
state.completedActivationIds.add(commandId);
|
|
1155
|
+
while (state.completedActivationIds.size > 32) {
|
|
1156
|
+
state.completedActivationIds.delete(
|
|
1157
|
+
state.completedActivationIds.values().next().value,
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
let eventLoopStarted = false;
|
|
1163
|
+
function resetLiveState() {
|
|
1164
|
+
state.liveMessages = [];
|
|
1165
|
+
state.liveRunning = false;
|
|
1166
|
+
state.livePhase = "idle";
|
|
1167
|
+
state.liveRetry = null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function connectEvents() {
|
|
1171
|
+
if (eventLoopStarted) return;
|
|
1172
|
+
eventLoopStarted = true;
|
|
1173
|
+
let reconnectDelay = 500;
|
|
1174
|
+
while (true) {
|
|
1175
|
+
let reader = null;
|
|
1176
|
+
let recoveryAttempted = false;
|
|
1177
|
+
try {
|
|
1178
|
+
if (state.cursor === null) {
|
|
1179
|
+
recoveryAttempted = true;
|
|
1180
|
+
const ready = await refreshSnapshot({ resetCursor: true });
|
|
1181
|
+
if (!ready) throw new Error("snapshot unavailable");
|
|
1182
|
+
recoveryAttempted = false;
|
|
1183
|
+
}
|
|
1184
|
+
const response = await fetch(`/events?cursor=${state.cursor}`, {
|
|
1185
|
+
headers: headers(),
|
|
1186
|
+
});
|
|
1187
|
+
if (response.status === 409) {
|
|
1188
|
+
recoveryAttempted = true;
|
|
1189
|
+
const ready = await refreshSnapshot({ resetCursor: true });
|
|
1190
|
+
if (!ready) throw new Error("snapshot resync failed");
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
if (!response.ok || !response.body) throw new Error("event connection failed");
|
|
1194
|
+
$("connection-state").textContent = "Connected";
|
|
1195
|
+
$("connection-state").classList.remove("reconnecting");
|
|
1196
|
+
reconnectDelay = 500;
|
|
1197
|
+
reader = response.body.getReader();
|
|
1198
|
+
const decoder = new TextDecoder();
|
|
1199
|
+
let buffer = "";
|
|
1200
|
+
while (true) {
|
|
1201
|
+
const { done, value } = await reader.read();
|
|
1202
|
+
if (done) throw new Error("event connection closed");
|
|
1203
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1204
|
+
const records = buffer.split("\n\n");
|
|
1205
|
+
buffer = records.pop() || "";
|
|
1206
|
+
for (const record of records) {
|
|
1207
|
+
const line = record.split("\n").find((item) => item.startsWith("data: "));
|
|
1208
|
+
if (!line) continue;
|
|
1209
|
+
const event = JSON.parse(line.slice(6));
|
|
1210
|
+
if (!Number.isSafeInteger(event.sequence)) throw new Error("invalid event cursor");
|
|
1211
|
+
if (event.sequence <= state.cursor) continue;
|
|
1212
|
+
if (event.sequence !== state.cursor + 1) throw new Error("event cursor gap");
|
|
1213
|
+
state.cursor = event.sequence;
|
|
1214
|
+
if (event.type === "state_invalidated") throw new Error("state invalidated");
|
|
1215
|
+
applyRuntimeEvent(event);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
} catch {
|
|
1219
|
+
await reader?.cancel().catch(() => undefined);
|
|
1220
|
+
reader = null;
|
|
1221
|
+
$("connection-state").textContent = "Reconnecting";
|
|
1222
|
+
$("connection-state").classList.add("reconnecting");
|
|
1223
|
+
const recovered = recoveryAttempted
|
|
1224
|
+
? false
|
|
1225
|
+
: await refreshSnapshot({ resetCursor: true });
|
|
1226
|
+
if (!recovered) resetLiveState();
|
|
1227
|
+
await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
|
|
1228
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 5_000);
|
|
1229
|
+
} finally {
|
|
1230
|
+
await reader?.cancel().catch(() => undefined);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const collapseButton = $("collapse-sidebar");
|
|
1236
|
+
const collapsedStorageKey = "openpi.sidebar-collapsed";
|
|
1237
|
+
const setSidebarCollapsed = (collapsed) => {
|
|
1238
|
+
document.body.classList.toggle("sidebar-collapsed", collapsed);
|
|
1239
|
+
collapseButton?.setAttribute("aria-label", collapsed ? "Expand sidebar" : "Collapse sidebar");
|
|
1240
|
+
collapseButton?.setAttribute("title", collapsed ? "Expand sidebar" : "Collapse sidebar");
|
|
1241
|
+
try { sessionStorage.setItem(collapsedStorageKey, String(collapsed)); } catch {}
|
|
1242
|
+
};
|
|
1243
|
+
try { setSidebarCollapsed(sessionStorage.getItem(collapsedStorageKey) === "true"); } catch {}
|
|
1244
|
+
collapseButton?.addEventListener("click", () => {
|
|
1245
|
+
if (window.matchMedia("(max-width: 760px)").matches) {
|
|
1246
|
+
document.body.classList.remove("sidebar-open");
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
setSidebarCollapsed(!document.body.classList.contains("sidebar-collapsed"));
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
$("search-toggle")?.addEventListener("click", () => {
|
|
1253
|
+
const search = $("search");
|
|
1254
|
+
const heading = search?.closest(".workspace-heading");
|
|
1255
|
+
if (!search || !heading) return;
|
|
1256
|
+
if (heading.classList.contains("is-searching")) {
|
|
1257
|
+
closeSearch();
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
heading.classList.add("is-searching");
|
|
1261
|
+
search.focus();
|
|
1262
|
+
});
|
|
1263
|
+
$("search")?.addEventListener("input", (event) => {
|
|
1264
|
+
state.query = event.target.value.trim();
|
|
1265
|
+
renderWorkspaces();
|
|
1266
|
+
});
|
|
1267
|
+
$("search")?.addEventListener("keydown", (event) => {
|
|
1268
|
+
if (event.key !== "Escape") return;
|
|
1269
|
+
event.preventDefault();
|
|
1270
|
+
closeSearch();
|
|
1271
|
+
});
|
|
1272
|
+
$("search-clear")?.addEventListener("click", () => {
|
|
1273
|
+
closeSearch();
|
|
1274
|
+
});
|
|
1275
|
+
$("workspace-menu")?.addEventListener("click", (event) => {
|
|
1276
|
+
const action = event.target.closest("[data-action]")?.dataset.action;
|
|
1277
|
+
const path = $("workspace-menu").dataset.workspace;
|
|
1278
|
+
if (!action || !path) return;
|
|
1279
|
+
if (action === "rename") openWorkspaceRename(path);
|
|
1280
|
+
else requestRemoveWorkspace(path);
|
|
1281
|
+
});
|
|
1282
|
+
$("session-menu")?.addEventListener("click", (event) => {
|
|
1283
|
+
const action = event.target.closest("[data-action]")?.dataset.action;
|
|
1284
|
+
const path = $("session-menu").dataset.session;
|
|
1285
|
+
if (!action || !path) return;
|
|
1286
|
+
if (action === "rename") openSessionRename(path);
|
|
1287
|
+
else if (action === "archive") void archiveSession(path);
|
|
1288
|
+
});
|
|
1289
|
+
$("workspace-rename-form")?.addEventListener("submit", async (event) => {
|
|
1290
|
+
if (event.submitter?.value !== "save") return;
|
|
1291
|
+
event.preventDefault();
|
|
1292
|
+
const dialog = $("workspace-rename-dialog");
|
|
1293
|
+
const path = dialog.dataset.workspace;
|
|
1294
|
+
const name = $("workspace-name-input").value.trim();
|
|
1295
|
+
if (!path || !name) return;
|
|
1296
|
+
try {
|
|
1297
|
+
await api("/api/workspaces", {
|
|
1298
|
+
method: "PATCH",
|
|
1299
|
+
body: JSON.stringify({ path, name }),
|
|
1300
|
+
});
|
|
1301
|
+
dialog.close();
|
|
1302
|
+
await refreshSnapshot();
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
showNotice(error.message);
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
$("workspace-delete-form")?.addEventListener("submit", async (event) => {
|
|
1308
|
+
if (event.submitter?.value !== "delete") return;
|
|
1309
|
+
const dialog = $("workspace-delete-dialog");
|
|
1310
|
+
const path = dialog?.dataset.workspace;
|
|
1311
|
+
if (path) await removeWorkspace(path);
|
|
1312
|
+
});
|
|
1313
|
+
$("session-rename-form")?.addEventListener("submit", async (event) => {
|
|
1314
|
+
if (event.submitter?.value !== "save") return;
|
|
1315
|
+
event.preventDefault();
|
|
1316
|
+
const dialog = $("session-rename-dialog");
|
|
1317
|
+
const path = dialog.dataset.session;
|
|
1318
|
+
const name = $("session-name-input").value.trim();
|
|
1319
|
+
if (!path || !name) return;
|
|
1320
|
+
try {
|
|
1321
|
+
await api("/api/sessions", {
|
|
1322
|
+
method: "PATCH",
|
|
1323
|
+
body: JSON.stringify({ path, name }),
|
|
1324
|
+
});
|
|
1325
|
+
dialog.close();
|
|
1326
|
+
await refreshSnapshot();
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
showNotice(error.message);
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1332
|
+
const menu = $("workspace-menu");
|
|
1333
|
+
if (menu?.open && !menu.contains(event.target) && !event.target.closest('[data-workspace-action="more"]')) menu.close();
|
|
1334
|
+
const sessionMenu = $("session-menu");
|
|
1335
|
+
if (sessionMenu?.open && !sessionMenu.contains(event.target) && !event.target.closest('[data-session-action="menu"]')) sessionMenu.close();
|
|
1336
|
+
const heading = document.querySelector(".workspace-heading");
|
|
1337
|
+
if (heading?.classList.contains("is-searching") && !heading.contains(event.target)) {
|
|
1338
|
+
closeSearch({ restoreFocus: false });
|
|
1339
|
+
}
|
|
1340
|
+
const picker = document.querySelector(".workspace-picker-row");
|
|
1341
|
+
if (picker && !picker.contains(event.target)) {
|
|
1342
|
+
$("workspace-picker-menu").hidden = true;
|
|
1343
|
+
$("select-workspace")?.setAttribute("aria-expanded", "false");
|
|
1344
|
+
}
|
|
1345
|
+
const modelPicker = document.querySelector(".model-picker-wrap");
|
|
1346
|
+
if (modelPicker && !modelPicker.contains(event.target)) {
|
|
1347
|
+
$("model-menu").hidden = true;
|
|
1348
|
+
$("model-picker")?.setAttribute("aria-expanded", "false");
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
document.querySelector(".new-session-button")?.addEventListener("click", async () => {
|
|
1352
|
+
if (!state.selectedWorkspace) await chooseWorkspace();
|
|
1353
|
+
if (state.selectedWorkspace) await createSession(state.selectedWorkspace);
|
|
1354
|
+
});
|
|
1355
|
+
$("import-workspace")?.addEventListener("click", chooseWorkspace);
|
|
1356
|
+
$("select-workspace")?.addEventListener("click", () => {
|
|
1357
|
+
const menu = $("workspace-picker-menu");
|
|
1358
|
+
const picker = $("select-workspace");
|
|
1359
|
+
if (!menu || !picker) return;
|
|
1360
|
+
if ((state.snapshot?.workspaces || []).length === 0) {
|
|
1361
|
+
void chooseWorkspace();
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
menu.hidden = !menu.hidden;
|
|
1365
|
+
picker.setAttribute("aria-expanded", String(!menu.hidden));
|
|
1366
|
+
if (!menu.hidden) renderWorkspacePicker();
|
|
1367
|
+
});
|
|
1368
|
+
$("model-picker")?.addEventListener("click", () => {
|
|
1369
|
+
const picker = $("model-picker");
|
|
1370
|
+
const menu = $("model-menu");
|
|
1371
|
+
if (!picker || !menu || picker.disabled) return;
|
|
1372
|
+
menu.hidden = !menu.hidden;
|
|
1373
|
+
picker.setAttribute("aria-expanded", String(!menu.hidden));
|
|
1374
|
+
});
|
|
1375
|
+
$("mobile-sidebar")?.addEventListener("click", () => document.body.classList.toggle("sidebar-open"));
|
|
1376
|
+
document.querySelector(".sidebar-scrim")?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
|
1377
|
+
$("composer")?.addEventListener("pointerdown", (event) => {
|
|
1378
|
+
if (state.selectedWorkspace) return;
|
|
1379
|
+
event.preventDefault();
|
|
1380
|
+
void chooseWorkspace();
|
|
1381
|
+
}, true);
|
|
1382
|
+
$("composer")?.addEventListener("submit", (event) => {
|
|
1383
|
+
event.preventDefault();
|
|
1384
|
+
if (state.selectedWorkspace) void sendPrompt();
|
|
1385
|
+
else void chooseWorkspace();
|
|
1386
|
+
});
|
|
1387
|
+
$("prompt-input")?.addEventListener("input", resizePrompt);
|
|
1388
|
+
$("prompt-input")?.addEventListener("keydown", (event) => {
|
|
1389
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
1390
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1391
|
+
event.preventDefault();
|
|
1392
|
+
if (state.selectedWorkspace) void sendPrompt();
|
|
1393
|
+
else void chooseWorkspace();
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
applyLanguage();
|
|
1398
|
+
void connectEvents();
|