@larose/pi-web 0.3.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/LICENSE +235 -0
- package/README.md +50 -0
- package/THIRD_PARTY_LICENSES.md +40 -0
- package/dist/client/home.js +1619 -0
- package/dist/client/session.js +3703 -0
- package/dist/server/api.js +485 -0
- package/dist/server/cli.js +51 -0
- package/dist/server/directory-browser.js +104 -0
- package/dist/server/errors.js +10 -0
- package/dist/server/event-buffer.js +40 -0
- package/dist/server/extension-ui.js +245 -0
- package/dist/server/git-workspaces.js +559 -0
- package/dist/server/runtime-registry.js +703 -0
- package/dist/server/server.js +190 -0
- package/dist/server/session-repository.js +374 -0
- package/package.json +46 -0
- package/public/home.html +139 -0
- package/public/session.html +144 -0
- package/public/styles.css +2463 -0
- package/screenshots/home.png +0 -0
- package/screenshots/session.png +0 -0
- package/src/client/display-title.ts +36 -0
- package/src/client/event-stream.ts +194 -0
- package/src/client/home.ts +1575 -0
- package/src/client/markdown.ts +98 -0
- package/src/client/message-queue.ts +67 -0
- package/src/client/path-combobox.ts +271 -0
- package/src/client/session.ts +2174 -0
- package/src/client/shared.ts +99 -0
- package/src/client/slash-completion.ts +184 -0
- package/src/client/transcript-activity.ts +188 -0
- package/src/client/usage-format.ts +156 -0
- package/src/client/workspace-browser.ts +36 -0
- package/src/server/api.ts +652 -0
- package/src/server/cli.ts +63 -0
- package/src/server/directory-browser.ts +137 -0
- package/src/server/errors.ts +11 -0
- package/src/server/event-buffer.ts +59 -0
- package/src/server/extension-ui.ts +359 -0
- package/src/server/git-workspaces.ts +750 -0
- package/src/server/runtime-registry.ts +943 -0
- package/src/server/server.ts +248 -0
- package/src/server/session-repository.ts +488 -0
|
@@ -0,0 +1,2174 @@
|
|
|
1
|
+
import { displaySessionTitle } from "./display-title.js";
|
|
2
|
+
import { SessionEventStream, type EventStreamState } from "./event-stream.js";
|
|
3
|
+
import { renderMarkdown } from "./markdown.js";
|
|
4
|
+
import {
|
|
5
|
+
copyMessageQueue,
|
|
6
|
+
messageQueueFromEvent,
|
|
7
|
+
reconcileMessageQueue,
|
|
8
|
+
steeringQueueGrew,
|
|
9
|
+
withoutSteeringMessage,
|
|
10
|
+
type MessageQueue,
|
|
11
|
+
} from "./message-queue.js";
|
|
12
|
+
import {
|
|
13
|
+
completeSlashCommand,
|
|
14
|
+
createSlashCompletionState,
|
|
15
|
+
getSlashCompletionWindow,
|
|
16
|
+
moveSlashCompletionSelection,
|
|
17
|
+
type SlashCompletionState,
|
|
18
|
+
} from "./slash-completion.js";
|
|
19
|
+
import { api, type GitContext, readableError, requiredElement, sessionPath, textElement } from "./shared.js";
|
|
20
|
+
import {
|
|
21
|
+
activityPreview,
|
|
22
|
+
groupTranscriptActivity,
|
|
23
|
+
partitionAssistantContent,
|
|
24
|
+
toolActionLabel,
|
|
25
|
+
type ActivityGroup,
|
|
26
|
+
type ActivityItem,
|
|
27
|
+
type TranscriptMessage,
|
|
28
|
+
} from "./transcript-activity.js";
|
|
29
|
+
import { formatSessionUsage, type SessionUsage } from "./usage-format.js";
|
|
30
|
+
import { relativePathWithin } from "./workspace-browser.js";
|
|
31
|
+
|
|
32
|
+
interface AgentMessage extends TranscriptMessage {}
|
|
33
|
+
|
|
34
|
+
interface PersistedMessage {
|
|
35
|
+
entryId: string;
|
|
36
|
+
message: AgentMessage;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PersistedCustomEntry {
|
|
40
|
+
entryId: string;
|
|
41
|
+
customType: string;
|
|
42
|
+
data: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type PersistedTranscriptEntry = ({ kind: "message" } & PersistedMessage) | ({ kind: "custom" } & PersistedCustomEntry);
|
|
46
|
+
|
|
47
|
+
interface PersistedSession {
|
|
48
|
+
id: string;
|
|
49
|
+
cwd: string;
|
|
50
|
+
gitContext: GitContext | null;
|
|
51
|
+
name?: string;
|
|
52
|
+
created: string;
|
|
53
|
+
modified: string;
|
|
54
|
+
messageCount: number;
|
|
55
|
+
firstMessage: string;
|
|
56
|
+
transcriptEntries: PersistedTranscriptEntry[];
|
|
57
|
+
model: { provider: string; modelId: string } | null;
|
|
58
|
+
thinkingLevel: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ExtensionUIDialogRequest =
|
|
62
|
+
| { id: string; method: "select"; title: string; options: string[]; timeout?: number }
|
|
63
|
+
| { id: string; method: "confirm"; title: string; message: string; timeout?: number }
|
|
64
|
+
| { id: string; method: "input"; title: string; placeholder?: string; timeout?: number }
|
|
65
|
+
| { id: string; method: "editor"; title: string; prefill?: string };
|
|
66
|
+
|
|
67
|
+
type ExtensionUIRequest =
|
|
68
|
+
| ExtensionUIDialogRequest
|
|
69
|
+
| { id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" }
|
|
70
|
+
| { id: string; method: "setStatus"; statusKey: string; statusText?: string }
|
|
71
|
+
| {
|
|
72
|
+
id: string;
|
|
73
|
+
method: "setWidget";
|
|
74
|
+
widgetKey: string;
|
|
75
|
+
widgetLines?: string[];
|
|
76
|
+
widgetPlacement?: "aboveEditor" | "belowEditor";
|
|
77
|
+
}
|
|
78
|
+
| { id: string; method: "setTitle"; title: string }
|
|
79
|
+
| { id: string; method: "set_editor_text"; text: string };
|
|
80
|
+
|
|
81
|
+
interface ExtensionUIState {
|
|
82
|
+
pending: ExtensionUIDialogRequest[];
|
|
83
|
+
statuses: Array<{ key: string; text: string }>;
|
|
84
|
+
widgets: Array<{ key: string; lines: string[]; placement: "aboveEditor" | "belowEditor" }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface RuntimeCommand {
|
|
88
|
+
name: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
source: "extension" | "prompt" | "skill";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface RuntimeState {
|
|
94
|
+
id: string;
|
|
95
|
+
cwd: string;
|
|
96
|
+
sessionName?: string;
|
|
97
|
+
isStreaming: boolean;
|
|
98
|
+
isCompacting: boolean;
|
|
99
|
+
isWorking: boolean;
|
|
100
|
+
pendingMessageCount: number;
|
|
101
|
+
queue: MessageQueue;
|
|
102
|
+
model: { provider: string; id: string; name: string } | null;
|
|
103
|
+
thinkingLevel: string;
|
|
104
|
+
autoCompactionEnabled: boolean;
|
|
105
|
+
usage: SessionUsage;
|
|
106
|
+
commands: RuntimeCommand[];
|
|
107
|
+
extensionUI: ExtensionUIState;
|
|
108
|
+
streamingMessage?: AgentMessage;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface SessionResponse {
|
|
112
|
+
session: PersistedSession;
|
|
113
|
+
runtime: RuntimeState | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type RuntimeEnvelope =
|
|
117
|
+
| { type: "agent_event"; event: Record<string, unknown> }
|
|
118
|
+
| ({ type: "extension_ui_request" } & ExtensionUIRequest)
|
|
119
|
+
| { type: "extension_ui_closed"; id: string }
|
|
120
|
+
| { type: "extension_ui_reset" }
|
|
121
|
+
| { type: "session_replaced"; previousId: string; sessionId: string }
|
|
122
|
+
| { type: "runtime_error"; message: string }
|
|
123
|
+
| { type: "runtime_disposed" };
|
|
124
|
+
|
|
125
|
+
interface LiveBlock {
|
|
126
|
+
kind: string;
|
|
127
|
+
target: HTMLElement;
|
|
128
|
+
summary?: HTMLElement;
|
|
129
|
+
source?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface LiveMessage {
|
|
133
|
+
root: HTMLElement;
|
|
134
|
+
content: HTMLElement;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ActivityCard {
|
|
138
|
+
root: HTMLDetailsElement;
|
|
139
|
+
content: HTMLElement;
|
|
140
|
+
title: HTMLElement;
|
|
141
|
+
action: HTMLElement;
|
|
142
|
+
statusText: HTMLElement;
|
|
143
|
+
manuallyToggled: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const elements = {
|
|
147
|
+
abort: requiredElement<HTMLButtonElement>("[data-abort]"),
|
|
148
|
+
composer: requiredElement<HTMLFormElement>("[data-composer]"),
|
|
149
|
+
connection: requiredElement<HTMLElement>("[data-connection]"),
|
|
150
|
+
createSession: requiredElement<HTMLAnchorElement>("[data-create-session]"),
|
|
151
|
+
extensionPrompt: requiredElement<HTMLElement>("[data-extension-prompt]"),
|
|
152
|
+
extensionStatusItem: requiredElement<HTMLElement>("[data-extension-status-item]"),
|
|
153
|
+
extensionStatuses: requiredElement<HTMLElement>("[data-extension-statuses]"),
|
|
154
|
+
extensionWidgetsAbove: requiredElement<HTMLElement>("[data-extension-widgets-above]"),
|
|
155
|
+
extensionWidgetsBelow: requiredElement<HTMLElement>("[data-extension-widgets-below]"),
|
|
156
|
+
prompt: requiredElement<HTMLTextAreaElement>("[data-prompt]"),
|
|
157
|
+
send: requiredElement<HTMLButtonElement>("[data-send]"),
|
|
158
|
+
sessionContext: requiredElement<HTMLElement>(".session-context"),
|
|
159
|
+
sessionContextToggle: requiredElement<HTMLButtonElement>("[data-session-context-toggle]"),
|
|
160
|
+
sessionContextUsage: requiredElement<HTMLElement>("[data-session-context-usage]"),
|
|
161
|
+
sessionModel: requiredElement<HTMLElement>("[data-session-model]"),
|
|
162
|
+
sessionTokens: requiredElement<HTMLElement>("[data-session-tokens]"),
|
|
163
|
+
sessionWorkspaceRepository: requiredElement<HTMLElement>("[data-session-workspace-repository]"),
|
|
164
|
+
sessionWorkspaceRepositoryPath: requiredElement<HTMLElement>("[data-session-workspace-repository-path]"),
|
|
165
|
+
sessionWorkspaceState: requiredElement<HTMLElement>("[data-session-workspace-state]"),
|
|
166
|
+
sessionWorkspaceWorkingDirectory: requiredElement<HTMLElement>("[data-session-workspace-working-directory]"),
|
|
167
|
+
sessionWorkspaceWorkingDirectoryPath: requiredElement<HTMLElement>("[data-session-workspace-working-directory-path]"),
|
|
168
|
+
sessionWorkspaceWorktree: requiredElement<HTMLElement>("[data-session-workspace-worktree]"),
|
|
169
|
+
sessionWorkspaceWorktreePath: requiredElement<HTMLElement>("[data-session-workspace-worktree-path]"),
|
|
170
|
+
slashCompletion: requiredElement<HTMLElement>("[data-slash-completion]"),
|
|
171
|
+
sessionTitle: requiredElement<HTMLButtonElement>("[data-session-title]"),
|
|
172
|
+
transcript: requiredElement<HTMLElement>("[data-transcript]"),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let currentSession: PersistedSession | undefined;
|
|
176
|
+
let currentRuntime: RuntimeState | null = null;
|
|
177
|
+
let currentSessionId: string | undefined;
|
|
178
|
+
let liveMessage: LiveMessage | undefined;
|
|
179
|
+
let liveActivity: ActivityCard | undefined;
|
|
180
|
+
let streamBlocks = new Map<number, LiveBlock>();
|
|
181
|
+
let markdownRenderFrame: number | undefined;
|
|
182
|
+
const pendingMarkdownBlocks = new Set<LiveBlock>();
|
|
183
|
+
let sending = false;
|
|
184
|
+
let renaming = false;
|
|
185
|
+
let extensionUI: ExtensionUIState = { pending: [], statuses: [], widgets: [] };
|
|
186
|
+
let renderedExtensionRequestId: string | undefined;
|
|
187
|
+
let slashCompletion: SlashCompletionState<RuntimeCommand> | null = null;
|
|
188
|
+
let dismissedSlashCompletionValue: string | undefined;
|
|
189
|
+
let reconcileSequence = 0;
|
|
190
|
+
let optimisticSubmission: { sessionId: string; message: string; root: HTMLElement } | undefined;
|
|
191
|
+
let submittedTurnAnchor:
|
|
192
|
+
| {
|
|
193
|
+
sessionId: string;
|
|
194
|
+
message: string;
|
|
195
|
+
root: HTMLElement;
|
|
196
|
+
needsAlignment: boolean;
|
|
197
|
+
}
|
|
198
|
+
| undefined;
|
|
199
|
+
let submittedTurnRunwayFrame: number | undefined;
|
|
200
|
+
let dequeuedSteering: string[] = [];
|
|
201
|
+
let discardingQueue = false;
|
|
202
|
+
const liveTools = new Map<string, HTMLElement>();
|
|
203
|
+
const SLASH_COMPLETION_MAX_VISIBLE = 5;
|
|
204
|
+
|
|
205
|
+
function currentNativeName(): string | undefined {
|
|
206
|
+
return currentRuntime?.sessionName?.trim() || currentSession?.name?.trim() || undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function updateSessionHeading(): void {
|
|
210
|
+
if (!currentSessionId) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const title = displaySessionTitle({
|
|
215
|
+
name: currentNativeName(),
|
|
216
|
+
firstMessage: currentSession?.firstMessage,
|
|
217
|
+
});
|
|
218
|
+
const renameLabel = `Rename session title: ${title}`;
|
|
219
|
+
elements.sessionTitle.textContent = title;
|
|
220
|
+
elements.sessionTitle.title = renameLabel;
|
|
221
|
+
elements.sessionTitle.setAttribute("aria-label", renameLabel);
|
|
222
|
+
document.title = `${title} · Pi`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function applyCurrentSessionName(name: string | undefined): void {
|
|
226
|
+
if (currentRuntime) {
|
|
227
|
+
if (name) {
|
|
228
|
+
currentRuntime.sessionName = name;
|
|
229
|
+
} else {
|
|
230
|
+
delete currentRuntime.sessionName;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (currentSession) {
|
|
234
|
+
if (name) {
|
|
235
|
+
currentSession.name = name;
|
|
236
|
+
} else {
|
|
237
|
+
delete currentSession.name;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
updateSessionHeading();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function setConnection(label: string, state: "offline" | "online" | "error"): void {
|
|
245
|
+
elements.connection.textContent = label;
|
|
246
|
+
elements.connection.dataset.state = state;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function setSessionContextCollapsed(collapsed: boolean): void {
|
|
250
|
+
const label = `${collapsed ? "Expand" : "Collapse"} session context`;
|
|
251
|
+
|
|
252
|
+
elements.sessionContext.classList.toggle("is-collapsed", collapsed);
|
|
253
|
+
elements.sessionContextToggle.setAttribute("aria-expanded", String(!collapsed));
|
|
254
|
+
elements.sessionContextToggle.setAttribute("aria-label", label);
|
|
255
|
+
elements.sessionContextToggle.title = label;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resizePrompt(): void {
|
|
259
|
+
elements.prompt.style.height = "auto";
|
|
260
|
+
elements.prompt.style.overflowY = "hidden";
|
|
261
|
+
|
|
262
|
+
const styles = getComputedStyle(elements.prompt);
|
|
263
|
+
const borderHeight = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth);
|
|
264
|
+
const naturalHeight = elements.prompt.scrollHeight + borderHeight;
|
|
265
|
+
elements.prompt.style.height = `${naturalHeight}px`;
|
|
266
|
+
|
|
267
|
+
const renderedHeight = elements.prompt.getBoundingClientRect().height;
|
|
268
|
+
elements.prompt.style.overflowY = naturalHeight > renderedHeight + 0.5 ? "auto" : "hidden";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function closeSlashCompletion(dismiss = false): void {
|
|
272
|
+
if (dismiss) {
|
|
273
|
+
dismissedSlashCompletionValue = elements.prompt.value;
|
|
274
|
+
}
|
|
275
|
+
slashCompletion = null;
|
|
276
|
+
elements.slashCompletion.hidden = true;
|
|
277
|
+
elements.slashCompletion.replaceChildren();
|
|
278
|
+
elements.prompt.setAttribute("aria-expanded", "false");
|
|
279
|
+
elements.prompt.removeAttribute("aria-activedescendant");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function applySelectedSlashCompletion(submit: boolean): void {
|
|
283
|
+
const command = slashCompletion?.matches[slashCompletion.selectedIndex];
|
|
284
|
+
if (!command) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setPromptValue(completeSlashCommand(command));
|
|
289
|
+
elements.prompt.setSelectionRange(elements.prompt.value.length, elements.prompt.value.length);
|
|
290
|
+
elements.prompt.focus();
|
|
291
|
+
|
|
292
|
+
if (submit) {
|
|
293
|
+
void sendMessage();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderSlashCompletion(resetSelection = false): void {
|
|
298
|
+
if (elements.prompt.disabled || dismissedSlashCompletionValue === elements.prompt.value) {
|
|
299
|
+
closeSlashCompletion();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const selectedName = resetSelection ? undefined : slashCompletion?.matches[slashCompletion.selectedIndex]?.name;
|
|
304
|
+
const nextState = createSlashCompletionState(currentRuntime?.commands ?? [], elements.prompt.value, selectedName);
|
|
305
|
+
if (!nextState) {
|
|
306
|
+
closeSlashCompletion();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
slashCompletion = nextState;
|
|
311
|
+
const visible = getSlashCompletionWindow(nextState, SLASH_COMPLETION_MAX_VISIBLE);
|
|
312
|
+
const options = visible.items.map((command, visibleIndex) => {
|
|
313
|
+
const index = visible.startIndex + visibleIndex;
|
|
314
|
+
const selected = index === nextState.selectedIndex;
|
|
315
|
+
const option = document.createElement("button");
|
|
316
|
+
option.type = "button";
|
|
317
|
+
option.className = "slash-completion-option";
|
|
318
|
+
option.id = `slash-command-option-${index}`;
|
|
319
|
+
option.dataset.commandIndex = String(index);
|
|
320
|
+
option.setAttribute("role", "option");
|
|
321
|
+
option.setAttribute("aria-selected", String(selected));
|
|
322
|
+
|
|
323
|
+
const name = textElement("span", "slash-completion-name", `/${command.name}`);
|
|
324
|
+
option.append(name);
|
|
325
|
+
if (command.description) {
|
|
326
|
+
option.append(textElement("span", "slash-completion-description", command.description));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
option.addEventListener("pointerdown", (event) => event.preventDefault());
|
|
330
|
+
option.addEventListener("click", () => {
|
|
331
|
+
if (!slashCompletion) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
slashCompletion = { ...slashCompletion, selectedIndex: index };
|
|
335
|
+
applySelectedSlashCompletion(false);
|
|
336
|
+
});
|
|
337
|
+
return option;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
elements.slashCompletion.replaceChildren(...options);
|
|
341
|
+
elements.slashCompletion.hidden = false;
|
|
342
|
+
elements.prompt.setAttribute("aria-expanded", "true");
|
|
343
|
+
elements.prompt.setAttribute("aria-activedescendant", `slash-command-option-${nextState.selectedIndex}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function setPromptValue(value: string): void {
|
|
347
|
+
dismissedSlashCompletionValue = undefined;
|
|
348
|
+
elements.prompt.value = value;
|
|
349
|
+
resizePrompt();
|
|
350
|
+
renderSlashCompletion(true);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function updateControls(): void {
|
|
354
|
+
const selected = currentSessionId !== undefined;
|
|
355
|
+
const working = currentRuntime?.isWorking ?? false;
|
|
356
|
+
elements.prompt.disabled = !selected;
|
|
357
|
+
elements.send.disabled = !selected || sending;
|
|
358
|
+
elements.abort.disabled = !selected || !working || sending;
|
|
359
|
+
elements.sessionTitle.disabled = !selected || renaming;
|
|
360
|
+
renderSlashCompletion();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function setContextValue(element: HTMLElement, value: string, title = value): void {
|
|
364
|
+
element.textContent = value;
|
|
365
|
+
element.title = value ? title : "";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function setWorkspaceRow(row: HTMLElement, path: HTMLElement, value = "", title = value): void {
|
|
369
|
+
row.hidden = !value;
|
|
370
|
+
setContextValue(path, value, title);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function setWorkspace(cwd: string, context: GitContext | null | undefined): void {
|
|
374
|
+
elements.sessionWorkspaceState.hidden = true;
|
|
375
|
+
setContextValue(elements.sessionWorkspaceState, "");
|
|
376
|
+
setWorkspaceRow(elements.sessionWorkspaceRepository, elements.sessionWorkspaceRepositoryPath);
|
|
377
|
+
setWorkspaceRow(elements.sessionWorkspaceWorktree, elements.sessionWorkspaceWorktreePath);
|
|
378
|
+
setWorkspaceRow(elements.sessionWorkspaceWorkingDirectory, elements.sessionWorkspaceWorkingDirectoryPath);
|
|
379
|
+
|
|
380
|
+
if (context === undefined) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (context === null) {
|
|
385
|
+
elements.sessionWorkspaceState.hidden = false;
|
|
386
|
+
setContextValue(elements.sessionWorkspaceState, "Not a Git working tree");
|
|
387
|
+
setWorkspaceRow(elements.sessionWorkspaceWorkingDirectory, elements.sessionWorkspaceWorkingDirectoryPath, cwd);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
setWorkspaceRow(elements.sessionWorkspaceRepository, elements.sessionWorkspaceRepositoryPath, context.repositoryRoot);
|
|
392
|
+
|
|
393
|
+
if (context.isLinkedWorktree) {
|
|
394
|
+
const relativeWorktree = relativePathWithin(context.repositoryRoot, context.worktreeRoot);
|
|
395
|
+
const worktreePath = relativeWorktree && relativeWorktree !== "." ? relativeWorktree : context.worktreeRoot;
|
|
396
|
+
setWorkspaceRow(
|
|
397
|
+
elements.sessionWorkspaceWorktree,
|
|
398
|
+
elements.sessionWorkspaceWorktreePath,
|
|
399
|
+
worktreePath,
|
|
400
|
+
context.worktreeRoot,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
setWorkspaceRow(elements.sessionWorkspaceWorkingDirectory, elements.sessionWorkspaceWorkingDirectoryPath, cwd);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function updateSessionUsage(usage: SessionUsage | null, autoCompactionEnabled = false): void {
|
|
408
|
+
const formatted = formatSessionUsage(usage, autoCompactionEnabled);
|
|
409
|
+
const tokenLines =
|
|
410
|
+
formatted.tokenLines.length > 0
|
|
411
|
+
? formatted.tokenLines.map((line) => textElement("span", "session-token-line", line))
|
|
412
|
+
: [textElement("span", "session-token-line is-empty", "No usage yet")];
|
|
413
|
+
|
|
414
|
+
for (const line of tokenLines) {
|
|
415
|
+
line.setAttribute("aria-hidden", "true");
|
|
416
|
+
}
|
|
417
|
+
elements.sessionTokens.replaceChildren(...tokenLines);
|
|
418
|
+
elements.sessionTokens.title = formatted.tokenAccessibleText;
|
|
419
|
+
elements.sessionTokens.setAttribute("aria-label", formatted.tokenAccessibleText);
|
|
420
|
+
|
|
421
|
+
const contextVisual = document.createElement("div");
|
|
422
|
+
contextVisual.className = "session-context-usage-visual";
|
|
423
|
+
contextVisual.setAttribute("aria-hidden", "true");
|
|
424
|
+
|
|
425
|
+
const contextSummary = document.createElement("div");
|
|
426
|
+
contextSummary.className = "session-context-usage-summary";
|
|
427
|
+
contextSummary.append(textElement("span", "session-context-percentage", formatted.context.percentageText));
|
|
428
|
+
if (formatted.context.capacityText) {
|
|
429
|
+
contextSummary.append(textElement("span", "session-context-capacity", formatted.context.capacityText));
|
|
430
|
+
}
|
|
431
|
+
contextVisual.append(contextSummary);
|
|
432
|
+
|
|
433
|
+
if (formatted.context.meterPercent !== null) {
|
|
434
|
+
const meter = document.createElement("span");
|
|
435
|
+
meter.className = "session-context-meter";
|
|
436
|
+
const fill = document.createElement("span");
|
|
437
|
+
fill.className = "session-context-meter-fill";
|
|
438
|
+
fill.style.width = `${formatted.context.meterPercent}%`;
|
|
439
|
+
meter.append(fill);
|
|
440
|
+
contextVisual.append(meter);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (formatted.context.autoCompactionEnabled) {
|
|
444
|
+
contextVisual.append(textElement("span", "session-context-auto", "Auto-compact enabled"));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
elements.sessionContextUsage.dataset.state = formatted.context.state;
|
|
448
|
+
elements.sessionContextUsage.replaceChildren(contextVisual);
|
|
449
|
+
elements.sessionContextUsage.title = formatted.context.accessibleText;
|
|
450
|
+
elements.sessionContextUsage.setAttribute("aria-label", formatted.context.accessibleText);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function updateSessionContext(): void {
|
|
454
|
+
const runtimeModel = currentRuntime?.model;
|
|
455
|
+
const persistedModel = currentSession?.model;
|
|
456
|
+
const modelIdentifier = runtimeModel
|
|
457
|
+
? `${runtimeModel.provider}/${runtimeModel.id}`
|
|
458
|
+
: persistedModel
|
|
459
|
+
? `${persistedModel.provider}/${persistedModel.modelId}`
|
|
460
|
+
: "Unavailable";
|
|
461
|
+
const modelName = runtimeModel?.name.trim() || modelIdentifier;
|
|
462
|
+
const thinking = currentRuntime?.thinkingLevel ?? currentSession?.thinkingLevel ?? "off";
|
|
463
|
+
const modelValue = modelIdentifier === "Unavailable" ? modelIdentifier : `${modelName} (${thinking})`;
|
|
464
|
+
const modelTitle =
|
|
465
|
+
modelIdentifier === "Unavailable" ? modelIdentifier : `${modelIdentifier} · Thinking level: ${thinking}`;
|
|
466
|
+
|
|
467
|
+
setContextValue(elements.sessionModel, modelValue, modelTitle);
|
|
468
|
+
elements.sessionModel.setAttribute(
|
|
469
|
+
"aria-label",
|
|
470
|
+
modelIdentifier === "Unavailable"
|
|
471
|
+
? "Model unavailable"
|
|
472
|
+
: `Model: ${modelName}; identifier: ${modelIdentifier}; thinking level: ${thinking}`,
|
|
473
|
+
);
|
|
474
|
+
updateSessionUsage(currentRuntime?.usage ?? null, currentRuntime?.autoCompactionEnabled);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function safeJson(value: unknown): string {
|
|
478
|
+
try {
|
|
479
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
480
|
+
} catch {
|
|
481
|
+
return String(value);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function createActivityCard(group?: ActivityGroup, running = false): ActivityCard {
|
|
486
|
+
const root = document.createElement("details");
|
|
487
|
+
root.className = "assistant-activity";
|
|
488
|
+
root.open = false;
|
|
489
|
+
root.dataset.state = running ? "running" : (group?.outcome ?? "complete");
|
|
490
|
+
|
|
491
|
+
const summary = document.createElement("summary");
|
|
492
|
+
const icon = document.createElement("span");
|
|
493
|
+
icon.className = "assistant-activity-status";
|
|
494
|
+
icon.setAttribute("aria-hidden", "true");
|
|
495
|
+
|
|
496
|
+
const summaryCopy = document.createElement("span");
|
|
497
|
+
summaryCopy.className = "assistant-activity-summary-copy";
|
|
498
|
+
const title = textElement(
|
|
499
|
+
"span",
|
|
500
|
+
"assistant-activity-title",
|
|
501
|
+
group?.preview ?? (running ? "Working…" : "Assistant activity"),
|
|
502
|
+
);
|
|
503
|
+
const action = textElement("span", "assistant-activity-action", "");
|
|
504
|
+
action.hidden = true;
|
|
505
|
+
summaryCopy.append(title, action);
|
|
506
|
+
|
|
507
|
+
const statusText = textElement(
|
|
508
|
+
"span",
|
|
509
|
+
"sr-only",
|
|
510
|
+
running
|
|
511
|
+
? "Assistant working."
|
|
512
|
+
: group?.outcome === "aborted"
|
|
513
|
+
? "Assistant work stopped."
|
|
514
|
+
: "Assistant work complete.",
|
|
515
|
+
);
|
|
516
|
+
summary.append(icon, summaryCopy, statusText);
|
|
517
|
+
|
|
518
|
+
const content = document.createElement("div");
|
|
519
|
+
content.className = "assistant-activity-content";
|
|
520
|
+
root.append(summary, content);
|
|
521
|
+
|
|
522
|
+
const card: ActivityCard = {
|
|
523
|
+
root,
|
|
524
|
+
content,
|
|
525
|
+
title,
|
|
526
|
+
action,
|
|
527
|
+
statusText,
|
|
528
|
+
manuallyToggled: false,
|
|
529
|
+
};
|
|
530
|
+
summary.addEventListener("click", () => {
|
|
531
|
+
card.manuallyToggled = true;
|
|
532
|
+
});
|
|
533
|
+
summary.addEventListener("keydown", (event) => {
|
|
534
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
535
|
+
card.manuallyToggled = true;
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (group) {
|
|
540
|
+
for (const item of group.items) {
|
|
541
|
+
appendActivityItem(card, item);
|
|
542
|
+
}
|
|
543
|
+
updateActivitySummary(card, group.preview, group.lastAction);
|
|
544
|
+
}
|
|
545
|
+
if (!running) {
|
|
546
|
+
card.action.hidden = true;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return card;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function updateActivitySummary(card: ActivityCard, preview?: string, action?: string): void {
|
|
553
|
+
if (preview) {
|
|
554
|
+
card.title.textContent = preview;
|
|
555
|
+
}
|
|
556
|
+
if (action) {
|
|
557
|
+
card.action.textContent = action;
|
|
558
|
+
card.action.hidden = false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function setActivityRunning(card: ActivityCard, running: boolean): void {
|
|
563
|
+
card.root.dataset.state = running ? "running" : "complete";
|
|
564
|
+
card.statusText.textContent = running ? "Assistant working." : "Assistant work complete.";
|
|
565
|
+
if (running && card.action.textContent) {
|
|
566
|
+
card.action.hidden = false;
|
|
567
|
+
} else if (!running) {
|
|
568
|
+
card.action.hidden = true;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function setActivityAborted(card: ActivityCard): void {
|
|
573
|
+
card.root.dataset.state = "aborted";
|
|
574
|
+
card.statusText.textContent = "Assistant work stopped.";
|
|
575
|
+
card.action.hidden = true;
|
|
576
|
+
collapseActivity(card);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function collapseActivity(card: ActivityCard): void {
|
|
580
|
+
if (!card.manuallyToggled) {
|
|
581
|
+
card.root.open = false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function appendActivityItem(card: ActivityCard, item: ActivityItem, append = true): HTMLElement {
|
|
586
|
+
const details = document.createElement("details");
|
|
587
|
+
details.className = `assistant-activity-item ${item.kind}`;
|
|
588
|
+
const summary = document.createElement("summary");
|
|
589
|
+
const body = document.createElement("div");
|
|
590
|
+
body.className = "assistant-activity-item-content";
|
|
591
|
+
|
|
592
|
+
if (item.kind === "thinking") {
|
|
593
|
+
summary.textContent = "Thinking";
|
|
594
|
+
body.append(textElement("pre", "", item.text));
|
|
595
|
+
const preview = activityPreview(item.text);
|
|
596
|
+
updateActivitySummary(card, preview || undefined);
|
|
597
|
+
} else if (item.kind === "toolCall") {
|
|
598
|
+
summary.textContent = toolActionLabel(item.name);
|
|
599
|
+
body.append(textElement("pre", "", safeJson(item.arguments)));
|
|
600
|
+
updateActivitySummary(card, undefined, toolActionLabel(item.name));
|
|
601
|
+
} else {
|
|
602
|
+
summary.textContent = `Tool result · ${item.message.toolName ?? "tool"}`;
|
|
603
|
+
appendContent(body, item.message.content);
|
|
604
|
+
updateActivitySummary(card, undefined, toolActionLabel(item.message.toolName, true));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
details.append(summary, body);
|
|
608
|
+
if (append) {
|
|
609
|
+
card.content.append(details);
|
|
610
|
+
}
|
|
611
|
+
return details;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function markdownElement(source: string): HTMLElement {
|
|
615
|
+
const element = document.createElement("div");
|
|
616
|
+
element.className = "message-text markdown-content";
|
|
617
|
+
element.innerHTML = renderMarkdown(source);
|
|
618
|
+
return element;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function appendContent(container: HTMLElement, content: unknown, markdown = false): void {
|
|
622
|
+
if (typeof content === "string") {
|
|
623
|
+
container.append(markdown ? markdownElement(content) : textElement("p", "message-text", content));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!Array.isArray(content)) {
|
|
628
|
+
if (content !== undefined) {
|
|
629
|
+
container.append(textElement("pre", "", safeJson(content)));
|
|
630
|
+
}
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const part of content) {
|
|
635
|
+
if (!part || typeof part !== "object") {
|
|
636
|
+
if (part !== undefined && part !== null) {
|
|
637
|
+
container.append(textElement("pre", "", safeJson(part)));
|
|
638
|
+
}
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const block = part as Record<string, unknown>;
|
|
642
|
+
|
|
643
|
+
if (block.type === "text") {
|
|
644
|
+
const text = typeof block.text === "string" ? block.text : "";
|
|
645
|
+
container.append(markdown ? markdownElement(text) : textElement("p", "message-text", text));
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (block.type === "thinking") {
|
|
650
|
+
const details = document.createElement("details");
|
|
651
|
+
const summary = document.createElement("summary");
|
|
652
|
+
summary.textContent = "Thinking";
|
|
653
|
+
details.append(summary, textElement("pre", "", typeof block.thinking === "string" ? block.thinking : ""));
|
|
654
|
+
container.append(details);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (block.type === "toolCall") {
|
|
659
|
+
const details = document.createElement("details");
|
|
660
|
+
const summary = document.createElement("summary");
|
|
661
|
+
summary.textContent = `Tool · ${String(block.name ?? "unknown")}`;
|
|
662
|
+
details.append(summary, textElement("pre", "", safeJson(block.arguments)));
|
|
663
|
+
container.append(details);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (block.type === "image") {
|
|
668
|
+
container.append(textElement("p", "message-text", "[Image]"));
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
container.append(textElement("pre", "", safeJson(block)));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function createMessage(message: AgentMessage, label?: string): HTMLElement {
|
|
677
|
+
const collapsible = message.role === "toolResult" || message.role === "live-tool";
|
|
678
|
+
let root: HTMLElement;
|
|
679
|
+
let header: HTMLElement;
|
|
680
|
+
let content: HTMLElement;
|
|
681
|
+
|
|
682
|
+
if (collapsible) {
|
|
683
|
+
root = document.createElement("details");
|
|
684
|
+
root.className = "message";
|
|
685
|
+
header = document.createElement("summary");
|
|
686
|
+
content = document.createElement("div");
|
|
687
|
+
content.className = "message-content";
|
|
688
|
+
root.append(header, content);
|
|
689
|
+
} else {
|
|
690
|
+
const template = requiredElement<HTMLTemplateElement>("#message-template");
|
|
691
|
+
const fragment = template.content.cloneNode(true) as DocumentFragment;
|
|
692
|
+
root = requiredElement<HTMLElement>("article", fragment);
|
|
693
|
+
header = requiredElement<HTMLElement>("header", root);
|
|
694
|
+
content = requiredElement<HTMLElement>(".message-content", root);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
root.classList.add(message.role);
|
|
698
|
+
if (message.isError) {
|
|
699
|
+
root.classList.add("error");
|
|
700
|
+
}
|
|
701
|
+
header.textContent =
|
|
702
|
+
label ??
|
|
703
|
+
(message.role === "toolResult"
|
|
704
|
+
? `Tool result · ${message.toolName ?? "tool"}`
|
|
705
|
+
: message.role === "custom"
|
|
706
|
+
? "extension"
|
|
707
|
+
: message.role);
|
|
708
|
+
appendContent(content, message.content, message.role === "assistant");
|
|
709
|
+
return root;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function renderEmpty(title: string, description: string): void {
|
|
713
|
+
const empty = document.createElement("div");
|
|
714
|
+
empty.className = "empty-state";
|
|
715
|
+
empty.append(textElement("strong", "", title), textElement("span", "", description));
|
|
716
|
+
elements.transcript.replaceChildren(empty);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function setSubmittedTurnAnchor(active: boolean): void {
|
|
720
|
+
elements.transcript.classList.toggle("has-submitted-turn-anchor", active);
|
|
721
|
+
if (!active) {
|
|
722
|
+
elements.transcript.style.removeProperty("--submitted-turn-runway");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function updateSubmittedTurnRunway(): void {
|
|
727
|
+
submittedTurnRunwayFrame = undefined;
|
|
728
|
+
const turn = submittedTurnAnchor;
|
|
729
|
+
if (!turn || turn.sessionId !== currentSessionId || !turn.root.isConnected) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const transcriptRect = elements.transcript.getBoundingClientRect();
|
|
734
|
+
const rootRect = turn.root.getBoundingClientRect();
|
|
735
|
+
const scrollPadding = Number.parseFloat(getComputedStyle(elements.transcript).scrollPaddingBlockStart) || 0;
|
|
736
|
+
const rootContentTop = elements.transcript.scrollTop + rootRect.top - transcriptRect.top;
|
|
737
|
+
const anchoredScrollTop = Math.max(0, rootContentTop - scrollPadding);
|
|
738
|
+
const previousScrollTop = elements.transcript.scrollTop;
|
|
739
|
+
|
|
740
|
+
elements.transcript.style.setProperty("--submitted-turn-runway", "0px");
|
|
741
|
+
const naturalScrollHeight = elements.transcript.scrollHeight;
|
|
742
|
+
const runway = Math.max(0, Math.ceil(anchoredScrollTop + elements.transcript.clientHeight - naturalScrollHeight));
|
|
743
|
+
elements.transcript.style.setProperty("--submitted-turn-runway", `${runway}px`);
|
|
744
|
+
|
|
745
|
+
const maximumScrollTop = elements.transcript.scrollHeight - elements.transcript.clientHeight;
|
|
746
|
+
elements.transcript.scrollTop = Math.min(previousScrollTop, maximumScrollTop);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function scheduleSubmittedTurnRunwayUpdate(): void {
|
|
750
|
+
if (submittedTurnRunwayFrame !== undefined) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
submittedTurnRunwayFrame = requestAnimationFrame(updateSubmittedTurnRunway);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function alignSubmittedTurn(root: HTMLElement): void {
|
|
758
|
+
const turn = submittedTurnAnchor;
|
|
759
|
+
if (!turn || turn.sessionId !== currentSessionId) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
turn.root = root;
|
|
764
|
+
updateSubmittedTurnRunway();
|
|
765
|
+
if (!turn.needsAlignment) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
root.scrollIntoView({ block: "start" });
|
|
770
|
+
turn.needsAlignment = false;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function insertBeforePendingQueue(node: Node): void {
|
|
774
|
+
const firstPending = elements.transcript.querySelector("[data-pending-steering]");
|
|
775
|
+
elements.transcript.insertBefore(node, firstPending);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function renderPendingQueue(): void {
|
|
779
|
+
for (const pending of elements.transcript.querySelectorAll("[data-pending-steering]")) {
|
|
780
|
+
pending.remove();
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const messages = currentRuntime?.queue.steering ?? [];
|
|
784
|
+
if (messages.length === 0) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
elements.transcript.querySelector(".empty-state")?.remove();
|
|
789
|
+
messages.forEach((message, index) => {
|
|
790
|
+
const root = createMessage({ role: "user", content: message });
|
|
791
|
+
root.classList.add("pending");
|
|
792
|
+
root.dataset.pendingSteering = "true";
|
|
793
|
+
root.dataset.state = "pending";
|
|
794
|
+
|
|
795
|
+
const header = requiredElement<HTMLElement>("header", root);
|
|
796
|
+
const label = textElement("span", "pending-message-label", "Pending");
|
|
797
|
+
const remove = textElement("button", "pending-message-remove", "×") as HTMLButtonElement;
|
|
798
|
+
const removeLabel = `Remove pending message ${index + 1}`;
|
|
799
|
+
remove.type = "button";
|
|
800
|
+
remove.disabled = sending;
|
|
801
|
+
remove.title = removeLabel;
|
|
802
|
+
remove.setAttribute("aria-label", removeLabel);
|
|
803
|
+
remove.addEventListener("click", () => void removePendingSteering(index, message));
|
|
804
|
+
header.replaceChildren(label, remove);
|
|
805
|
+
|
|
806
|
+
elements.transcript.append(root);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function anchorPendingSteeringMessage(index: number): void {
|
|
811
|
+
const pending = elements.transcript.querySelectorAll<HTMLElement>("[data-pending-steering]")[index];
|
|
812
|
+
if (pending) {
|
|
813
|
+
alignSubmittedTurn(pending);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function renderConversation(session: PersistedSession, runtime: RuntimeState | null): void {
|
|
818
|
+
const preservedActivityOpen = liveActivity?.manuallyToggled ? liveActivity.root.open : undefined;
|
|
819
|
+
const preservedActivityAborted = liveActivity?.root.dataset.state === "aborted";
|
|
820
|
+
liveMessage = undefined;
|
|
821
|
+
liveActivity = undefined;
|
|
822
|
+
resetStreamBlocks();
|
|
823
|
+
liveTools.clear();
|
|
824
|
+
|
|
825
|
+
const fragment = document.createDocumentFragment();
|
|
826
|
+
let latestTurnActivity: ActivityCard | undefined;
|
|
827
|
+
|
|
828
|
+
for (const entry of groupTranscriptActivity(session.transcriptEntries)) {
|
|
829
|
+
if (entry.kind === "activity") {
|
|
830
|
+
latestTurnActivity = createActivityCard(entry);
|
|
831
|
+
fragment.append(latestTurnActivity.root);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (entry.kind === "message") {
|
|
836
|
+
if (entry.message.role === "user") {
|
|
837
|
+
latestTurnActivity = undefined;
|
|
838
|
+
}
|
|
839
|
+
if (entry.message.role !== "custom" || entry.message.display !== false) {
|
|
840
|
+
fragment.append(createMessage(entry.message));
|
|
841
|
+
}
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (!entry.data || typeof entry.data !== "object") {
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const data = entry.data as { path?: unknown; markdown?: unknown };
|
|
850
|
+
if (typeof data.markdown !== "string") {
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const fileName = typeof data.path === "string" ? data.path.split("/").at(-1) : undefined;
|
|
855
|
+
const label = fileName ? `extension snapshot · ${fileName}` : "extension snapshot";
|
|
856
|
+
const snapshot = createMessage({ role: "extension", content: [] }, label);
|
|
857
|
+
requiredElement<HTMLElement>(".message-content", snapshot).append(markdownElement(data.markdown));
|
|
858
|
+
fragment.append(snapshot);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (fragment.childNodes.length === 0 && !runtime?.isWorking && !runtime?.queue.steering.length) {
|
|
862
|
+
renderEmpty("Start a conversation", "Send a message to begin this session.");
|
|
863
|
+
} else {
|
|
864
|
+
elements.transcript.replaceChildren(fragment);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (latestTurnActivity && preservedActivityOpen !== undefined) {
|
|
868
|
+
latestTurnActivity.manuallyToggled = true;
|
|
869
|
+
latestTurnActivity.root.open = preservedActivityOpen;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (runtime?.isWorking) {
|
|
873
|
+
liveActivity = latestTurnActivity ?? ensureLiveActivity();
|
|
874
|
+
if (preservedActivityOpen !== undefined) {
|
|
875
|
+
liveActivity.manuallyToggled = true;
|
|
876
|
+
liveActivity.root.open = preservedActivityOpen;
|
|
877
|
+
}
|
|
878
|
+
setActivityRunning(liveActivity, true);
|
|
879
|
+
if (runtime.streamingMessage) {
|
|
880
|
+
hydrateStreamingMessage(runtime.streamingMessage);
|
|
881
|
+
}
|
|
882
|
+
} else if (preservedActivityAborted) {
|
|
883
|
+
liveActivity = latestTurnActivity ?? ensureLiveActivity();
|
|
884
|
+
setActivityAborted(liveActivity);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
renderPendingQueue();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function ensureLiveActivity(): ActivityCard {
|
|
891
|
+
if (liveActivity) {
|
|
892
|
+
return liveActivity;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
elements.transcript.querySelector(".empty-state")?.remove();
|
|
896
|
+
liveActivity = createActivityCard(undefined, true);
|
|
897
|
+
insertBeforePendingQueue(liveActivity.root);
|
|
898
|
+
return liveActivity;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function ensureLiveMessage(): LiveMessage {
|
|
902
|
+
if (liveMessage) {
|
|
903
|
+
return liveMessage;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const activity = ensureLiveActivity();
|
|
907
|
+
collapseActivity(activity);
|
|
908
|
+
const root = createMessage({ role: "assistant", content: [] }, "assistant · streaming");
|
|
909
|
+
const content = requiredElement<HTMLElement>(".message-content", root);
|
|
910
|
+
liveMessage = { root, content };
|
|
911
|
+
insertBeforePendingQueue(root);
|
|
912
|
+
return liveMessage;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function renderLiveMarkdown(block: LiveBlock): void {
|
|
916
|
+
if (block.kind !== "text") {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
block.target.innerHTML = renderMarkdown(block.source ?? "");
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function flushLiveMarkdown(): void {
|
|
924
|
+
markdownRenderFrame = undefined;
|
|
925
|
+
const blocks = [...pendingMarkdownBlocks];
|
|
926
|
+
pendingMarkdownBlocks.clear();
|
|
927
|
+
for (const block of blocks) {
|
|
928
|
+
renderLiveMarkdown(block);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function scheduleLiveMarkdown(block: LiveBlock): void {
|
|
933
|
+
pendingMarkdownBlocks.add(block);
|
|
934
|
+
if (markdownRenderFrame !== undefined) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
markdownRenderFrame = requestAnimationFrame(flushLiveMarkdown);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function resetStreamBlocks(): void {
|
|
942
|
+
if (markdownRenderFrame !== undefined) {
|
|
943
|
+
cancelAnimationFrame(markdownRenderFrame);
|
|
944
|
+
}
|
|
945
|
+
markdownRenderFrame = undefined;
|
|
946
|
+
pendingMarkdownBlocks.clear();
|
|
947
|
+
streamBlocks = new Map();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function ensureStreamBlock(index: number, kind: string, initial?: Record<string, unknown>): LiveBlock {
|
|
951
|
+
const existing = streamBlocks.get(index);
|
|
952
|
+
if (existing) {
|
|
953
|
+
return existing;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
let block: LiveBlock;
|
|
957
|
+
if (kind === "thinking") {
|
|
958
|
+
const card = ensureLiveActivity();
|
|
959
|
+
const details = appendActivityItem(card, { kind: "thinking", text: "" });
|
|
960
|
+
block = {
|
|
961
|
+
kind,
|
|
962
|
+
target: requiredElement<HTMLElement>("pre", details),
|
|
963
|
+
summary: requiredElement<HTMLElement>("summary", details),
|
|
964
|
+
};
|
|
965
|
+
} else if (kind.toLowerCase().startsWith("toolcall")) {
|
|
966
|
+
const name = String(initial?.toolName ?? initial?.name ?? "tool");
|
|
967
|
+
const card = ensureLiveActivity();
|
|
968
|
+
const details = appendActivityItem(card, { kind: "toolCall", name, arguments: {} });
|
|
969
|
+
block = {
|
|
970
|
+
kind: "toolcall",
|
|
971
|
+
target: requiredElement<HTMLElement>("pre", details),
|
|
972
|
+
summary: requiredElement<HTMLElement>("summary", details),
|
|
973
|
+
};
|
|
974
|
+
} else {
|
|
975
|
+
const live = ensureLiveMessage();
|
|
976
|
+
const markdown = markdownElement("");
|
|
977
|
+
live.content.append(markdown);
|
|
978
|
+
block = { kind: "text", target: markdown, source: "" };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
streamBlocks.set(index, block);
|
|
982
|
+
return block;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function hydrateStreamingMessage(partial: AgentMessage): void {
|
|
986
|
+
resetStreamBlocks();
|
|
987
|
+
|
|
988
|
+
if (typeof partial.content === "string") {
|
|
989
|
+
const block = ensureStreamBlock(0, "text");
|
|
990
|
+
block.source = partial.content;
|
|
991
|
+
renderLiveMarkdown(block);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (!Array.isArray(partial.content)) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
partial.content.forEach((part, index) => {
|
|
999
|
+
if (!part || typeof part !== "object") {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const value = part as Record<string, unknown>;
|
|
1003
|
+
const kind = typeof value.type === "string" ? value.type : "text";
|
|
1004
|
+
const block = ensureStreamBlock(index, kind, value);
|
|
1005
|
+
|
|
1006
|
+
if (kind === "text" && typeof value.text === "string") {
|
|
1007
|
+
block.source = value.text;
|
|
1008
|
+
renderLiveMarkdown(block);
|
|
1009
|
+
}
|
|
1010
|
+
if (kind === "thinking" && typeof value.thinking === "string") {
|
|
1011
|
+
block.target.textContent = value.thinking;
|
|
1012
|
+
updateActivitySummary(ensureLiveActivity(), activityPreview(value.thinking) || undefined);
|
|
1013
|
+
}
|
|
1014
|
+
if (kind === "toolCall" && value.arguments !== undefined) {
|
|
1015
|
+
block.target.textContent = safeJson(value.arguments);
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function handleMessageUpdate(event: Record<string, unknown>): void {
|
|
1021
|
+
const update = event.assistantMessageEvent;
|
|
1022
|
+
if (!update || typeof update !== "object") {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const delta = update as Record<string, unknown>;
|
|
1026
|
+
const type = typeof delta.type === "string" ? delta.type : "";
|
|
1027
|
+
const index = typeof delta.contentIndex === "number" ? delta.contentIndex : 0;
|
|
1028
|
+
|
|
1029
|
+
if (type === "text_start") {
|
|
1030
|
+
ensureStreamBlock(index, "text");
|
|
1031
|
+
}
|
|
1032
|
+
if (type === "thinking_start") {
|
|
1033
|
+
ensureStreamBlock(index, "thinking");
|
|
1034
|
+
}
|
|
1035
|
+
if (type === "toolcall_start") {
|
|
1036
|
+
ensureStreamBlock(index, "toolcall", delta);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta") {
|
|
1040
|
+
const kind = type === "thinking_delta" ? "thinking" : type === "toolcall_delta" ? "toolcall" : "text";
|
|
1041
|
+
const block = ensureStreamBlock(index, kind, delta);
|
|
1042
|
+
if (typeof delta.delta === "string") {
|
|
1043
|
+
if (kind === "text") {
|
|
1044
|
+
block.source = (block.source ?? "") + delta.delta;
|
|
1045
|
+
scheduleLiveMarkdown(block);
|
|
1046
|
+
} else {
|
|
1047
|
+
block.target.append(document.createTextNode(delta.delta));
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (kind === "thinking") {
|
|
1051
|
+
updateActivitySummary(ensureLiveActivity(), activityPreview(block.target.textContent ?? "") || undefined);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (type === "toolcall_end") {
|
|
1056
|
+
const block = ensureStreamBlock(index, "toolcall", delta);
|
|
1057
|
+
const toolCall = delta.toolCall as Record<string, unknown> | undefined;
|
|
1058
|
+
const name = toolCall?.name ?? delta.toolName;
|
|
1059
|
+
if (block.summary) {
|
|
1060
|
+
block.summary.textContent = toolActionLabel(name);
|
|
1061
|
+
}
|
|
1062
|
+
if (toolCall?.arguments !== undefined) {
|
|
1063
|
+
block.target.textContent = safeJson(toolCall.arguments);
|
|
1064
|
+
}
|
|
1065
|
+
updateActivitySummary(ensureLiveActivity(), undefined, toolActionLabel(name));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function resultText(value: unknown): string {
|
|
1070
|
+
if (!value || typeof value !== "object") {
|
|
1071
|
+
return "";
|
|
1072
|
+
}
|
|
1073
|
+
const content = (value as { content?: unknown }).content;
|
|
1074
|
+
if (!Array.isArray(content)) {
|
|
1075
|
+
return "";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return content
|
|
1079
|
+
.filter((part): part is { type: string; text: string } =>
|
|
1080
|
+
Boolean(
|
|
1081
|
+
part &&
|
|
1082
|
+
typeof part === "object" &&
|
|
1083
|
+
(part as { type?: unknown }).type === "text" &&
|
|
1084
|
+
typeof (part as { text?: unknown }).text === "string",
|
|
1085
|
+
),
|
|
1086
|
+
)
|
|
1087
|
+
.map((part) => part.text)
|
|
1088
|
+
.join("\n");
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function updateTool(event: Record<string, unknown>, finished: boolean): void {
|
|
1092
|
+
const id = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
1093
|
+
if (!id) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const card = ensureLiveActivity();
|
|
1098
|
+
const name = String(event.toolName ?? "tool");
|
|
1099
|
+
let root = liveTools.get(id);
|
|
1100
|
+
if (!root) {
|
|
1101
|
+
root = document.createElement("details");
|
|
1102
|
+
root.className = "assistant-activity-item toolResult live-tool";
|
|
1103
|
+
const summary = textElement("summary", "", `Tool result · ${name}`);
|
|
1104
|
+
const content = document.createElement("div");
|
|
1105
|
+
content.className = "assistant-activity-item-content";
|
|
1106
|
+
root.append(summary, content);
|
|
1107
|
+
card.content.append(root);
|
|
1108
|
+
liveTools.set(id, root);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const content = requiredElement<HTMLElement>(".assistant-activity-item-content", root);
|
|
1112
|
+
const result = finished ? event.result : event.partialResult;
|
|
1113
|
+
content.replaceChildren(textElement("pre", "", resultText(result)));
|
|
1114
|
+
if (finished) {
|
|
1115
|
+
requiredElement<HTMLElement>("summary", root).textContent = `Tool result · ${name} · done`;
|
|
1116
|
+
}
|
|
1117
|
+
updateActivitySummary(card, undefined, toolActionLabel(name, finished));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function messageText(message: AgentMessage): string | undefined {
|
|
1121
|
+
if (typeof message.content === "string") {
|
|
1122
|
+
return message.content;
|
|
1123
|
+
}
|
|
1124
|
+
if (!Array.isArray(message.content)) {
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const text = message.content
|
|
1129
|
+
.filter((part): part is { type: string; text: string } =>
|
|
1130
|
+
Boolean(
|
|
1131
|
+
part &&
|
|
1132
|
+
typeof part === "object" &&
|
|
1133
|
+
(part as { type?: unknown }).type === "text" &&
|
|
1134
|
+
typeof (part as { text?: unknown }).text === "string",
|
|
1135
|
+
),
|
|
1136
|
+
)
|
|
1137
|
+
.map((part) => part.text)
|
|
1138
|
+
.join("\n");
|
|
1139
|
+
return text || undefined;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function appendUserMessage(message: AgentMessage): void {
|
|
1143
|
+
const text = messageText(message);
|
|
1144
|
+
const candidate = optimisticSubmission;
|
|
1145
|
+
const optimistic =
|
|
1146
|
+
candidate && candidate.sessionId === currentSessionId && candidate.root.isConnected && candidate.message === text
|
|
1147
|
+
? candidate
|
|
1148
|
+
: undefined;
|
|
1149
|
+
const dequeuedIndex = text === undefined ? -1 : dequeuedSteering.indexOf(text);
|
|
1150
|
+
if (!optimistic && dequeuedIndex < 0) {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (dequeuedIndex >= 0) {
|
|
1155
|
+
dequeuedSteering.splice(dequeuedIndex, 1);
|
|
1156
|
+
}
|
|
1157
|
+
const final = createMessage(message);
|
|
1158
|
+
if (optimistic) {
|
|
1159
|
+
optimistic.root.replaceWith(final);
|
|
1160
|
+
optimisticSubmission = undefined;
|
|
1161
|
+
alignSubmittedTurn(final);
|
|
1162
|
+
} else {
|
|
1163
|
+
insertBeforePendingQueue(final);
|
|
1164
|
+
const turn = submittedTurnAnchor;
|
|
1165
|
+
if (turn && turn.sessionId === currentSessionId && turn.message === text) {
|
|
1166
|
+
alignSubmittedTurn(final);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (!optimistic) {
|
|
1171
|
+
if (liveActivity) {
|
|
1172
|
+
setActivityRunning(liveActivity, false);
|
|
1173
|
+
collapseActivity(liveActivity);
|
|
1174
|
+
}
|
|
1175
|
+
liveMessage = undefined;
|
|
1176
|
+
liveActivity = undefined;
|
|
1177
|
+
resetStreamBlocks();
|
|
1178
|
+
liveTools.clear();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function appendMessage(message: AgentMessage): void {
|
|
1183
|
+
if (message.role === "custom" && message.display === false) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
elements.transcript.querySelector(".empty-state")?.remove();
|
|
1188
|
+
|
|
1189
|
+
if (message.role === "user") {
|
|
1190
|
+
appendUserMessage(message);
|
|
1191
|
+
} else if (message.role === "assistant") {
|
|
1192
|
+
const partitioned = partitionAssistantContent(message.content);
|
|
1193
|
+
const sawStreamedActivity = [...streamBlocks.values()].some((block) => block.kind !== "text");
|
|
1194
|
+
if (!sawStreamedActivity) {
|
|
1195
|
+
const card = partitioned.activity.length > 0 ? ensureLiveActivity() : undefined;
|
|
1196
|
+
if (card) {
|
|
1197
|
+
for (const item of partitioned.activity) {
|
|
1198
|
+
appendActivityItem(card, item);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (partitioned.hasResponse) {
|
|
1204
|
+
const final = createMessage({ ...message, content: partitioned.responseContent });
|
|
1205
|
+
collapseActivity(ensureLiveActivity());
|
|
1206
|
+
if (liveMessage) {
|
|
1207
|
+
liveMessage.root.replaceWith(final);
|
|
1208
|
+
} else {
|
|
1209
|
+
insertBeforePendingQueue(final);
|
|
1210
|
+
}
|
|
1211
|
+
liveMessage = undefined;
|
|
1212
|
+
}
|
|
1213
|
+
if (message.stopReason === "aborted") {
|
|
1214
|
+
setActivityAborted(ensureLiveActivity());
|
|
1215
|
+
}
|
|
1216
|
+
resetStreamBlocks();
|
|
1217
|
+
} else if (message.role === "toolResult") {
|
|
1218
|
+
const card = ensureLiveActivity();
|
|
1219
|
+
const placeholder = message.toolCallId ? liveTools.get(message.toolCallId) : undefined;
|
|
1220
|
+
const final = appendActivityItem(card, { kind: "toolResult", message }, !placeholder);
|
|
1221
|
+
if (placeholder) {
|
|
1222
|
+
placeholder.replaceWith(final);
|
|
1223
|
+
}
|
|
1224
|
+
if (message.toolCallId) {
|
|
1225
|
+
liveTools.delete(message.toolCallId);
|
|
1226
|
+
}
|
|
1227
|
+
} else {
|
|
1228
|
+
insertBeforePendingQueue(createMessage(message));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function appendError(message: string): void {
|
|
1233
|
+
const root = createMessage({ role: "error", content: message, isError: true }, "error");
|
|
1234
|
+
elements.transcript.querySelector(".empty-state")?.remove();
|
|
1235
|
+
insertBeforePendingQueue(root);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function cloneExtensionUIState(state: ExtensionUIState | undefined): ExtensionUIState {
|
|
1239
|
+
return state
|
|
1240
|
+
? {
|
|
1241
|
+
pending: state.pending.map((request) => ({ ...request })),
|
|
1242
|
+
statuses: state.statuses.map((status) => ({ ...status })),
|
|
1243
|
+
widgets: state.widgets.map((widget) => ({ ...widget, lines: [...widget.lines] })),
|
|
1244
|
+
}
|
|
1245
|
+
: { pending: [], statuses: [], widgets: [] };
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function renderExtensionState(): void {
|
|
1249
|
+
const statuses = document.createDocumentFragment();
|
|
1250
|
+
for (const status of extensionUI.statuses) {
|
|
1251
|
+
statuses.append(textElement("span", "extension-status", status.text));
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const above = document.createDocumentFragment();
|
|
1255
|
+
const below = document.createDocumentFragment();
|
|
1256
|
+
for (const widget of extensionUI.widgets) {
|
|
1257
|
+
const section = document.createElement("section");
|
|
1258
|
+
section.className = "extension-widget";
|
|
1259
|
+
section.append(textElement("pre", "extension-widget-content", widget.lines.join("\n")));
|
|
1260
|
+
(widget.placement === "belowEditor" ? below : above).append(section);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const hasStatuses = statuses.childNodes.length > 0;
|
|
1264
|
+
const hasAbove = above.childNodes.length > 0;
|
|
1265
|
+
const hasBelow = below.childNodes.length > 0;
|
|
1266
|
+
elements.extensionStatuses.replaceChildren(statuses);
|
|
1267
|
+
elements.extensionWidgetsAbove.replaceChildren(above);
|
|
1268
|
+
elements.extensionWidgetsBelow.replaceChildren(below);
|
|
1269
|
+
elements.extensionStatusItem.hidden = !hasStatuses;
|
|
1270
|
+
elements.extensionStatuses.hidden = !hasStatuses;
|
|
1271
|
+
elements.extensionWidgetsAbove.hidden = !hasAbove;
|
|
1272
|
+
elements.extensionWidgetsBelow.hidden = !hasBelow;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function respondToExtensionUI(
|
|
1276
|
+
request: ExtensionUIDialogRequest,
|
|
1277
|
+
response: { value: string } | { confirmed: boolean } | { cancelled: true },
|
|
1278
|
+
reconcileAfterResponse = true,
|
|
1279
|
+
): Promise<void> {
|
|
1280
|
+
if (!currentSessionId) {
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const sessionId = currentSessionId;
|
|
1285
|
+
applyExtensionUIState({
|
|
1286
|
+
...extensionUI,
|
|
1287
|
+
pending: extensionUI.pending.filter((candidate) => candidate.id !== request.id),
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
try {
|
|
1291
|
+
await api<{ accepted: boolean }>(`/api/sessions/${encodeURIComponent(sessionId)}/extension-ui`, {
|
|
1292
|
+
method: "POST",
|
|
1293
|
+
headers: { "content-type": "application/json" },
|
|
1294
|
+
body: JSON.stringify({ type: "extension_ui_response", id: request.id, ...response }),
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
if (currentSessionId === sessionId && reconcileAfterResponse) {
|
|
1298
|
+
await reconcileSession();
|
|
1299
|
+
}
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
appendError(`Could not answer extension request: ${readableError(error)}`);
|
|
1302
|
+
if (currentSessionId === sessionId && reconcileAfterResponse) {
|
|
1303
|
+
void reconcileSession();
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function renderExtensionPrompt(): void {
|
|
1309
|
+
const request = extensionUI.pending[0];
|
|
1310
|
+
if (!request) {
|
|
1311
|
+
const closedRequest = renderedExtensionRequestId !== undefined;
|
|
1312
|
+
renderedExtensionRequestId = undefined;
|
|
1313
|
+
elements.extensionPrompt.hidden = true;
|
|
1314
|
+
elements.extensionPrompt.replaceChildren();
|
|
1315
|
+
if (closedRequest && currentSessionId) {
|
|
1316
|
+
queueMicrotask(() => elements.prompt.focus());
|
|
1317
|
+
}
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (request.id === renderedExtensionRequestId && elements.extensionPrompt.childElementCount > 0) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
renderedExtensionRequestId = request.id;
|
|
1325
|
+
const panel = document.createElement("section");
|
|
1326
|
+
panel.className = "extension-prompt-panel";
|
|
1327
|
+
panel.setAttribute("role", "dialog");
|
|
1328
|
+
panel.setAttribute("aria-labelledby", "extension-prompt-title");
|
|
1329
|
+
panel.addEventListener("keydown", (event) => {
|
|
1330
|
+
if (event.key !== "Escape") {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
event.preventDefault();
|
|
1335
|
+
void respondToExtensionUI(request, { cancelled: true });
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
const header = document.createElement("header");
|
|
1339
|
+
const title = document.createElement("h2");
|
|
1340
|
+
title.id = "extension-prompt-title";
|
|
1341
|
+
const titleToggle = textElement("button", "extension-prompt-title-toggle", request.title) as HTMLButtonElement;
|
|
1342
|
+
titleToggle.type = "button";
|
|
1343
|
+
title.append(titleToggle);
|
|
1344
|
+
|
|
1345
|
+
const body = document.createElement("div");
|
|
1346
|
+
body.id = "extension-prompt-body";
|
|
1347
|
+
body.className = "extension-prompt-body";
|
|
1348
|
+
const actions = document.createElement("footer");
|
|
1349
|
+
actions.id = "extension-prompt-actions";
|
|
1350
|
+
actions.className = "extension-prompt-actions";
|
|
1351
|
+
|
|
1352
|
+
titleToggle.setAttribute("aria-controls", `${body.id} ${actions.id}`);
|
|
1353
|
+
|
|
1354
|
+
const setCollapsed = (collapsed: boolean): void => {
|
|
1355
|
+
const label = `${collapsed ? "Expand" : "Collapse"} extension request`;
|
|
1356
|
+
panel.classList.toggle("is-collapsed", collapsed);
|
|
1357
|
+
titleToggle.title = label;
|
|
1358
|
+
titleToggle.setAttribute("aria-expanded", String(!collapsed));
|
|
1359
|
+
};
|
|
1360
|
+
const toggleCollapsed = (): void => {
|
|
1361
|
+
setCollapsed(!panel.classList.contains("is-collapsed"));
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
setCollapsed(false);
|
|
1365
|
+
titleToggle.addEventListener("click", toggleCollapsed);
|
|
1366
|
+
titleToggle.addEventListener("keydown", (event) => {
|
|
1367
|
+
if (event.key !== "Enter" && event.key !== " ") {
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
event.preventDefault();
|
|
1372
|
+
toggleCollapsed();
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
const cancel = textElement("button", "extension-prompt-cancel secondary", "Cancel") as HTMLButtonElement;
|
|
1376
|
+
cancel.type = "button";
|
|
1377
|
+
cancel.addEventListener("click", () => void respondToExtensionUI(request, { cancelled: true }));
|
|
1378
|
+
|
|
1379
|
+
const headerActions = document.createElement("div");
|
|
1380
|
+
headerActions.className = "extension-prompt-header-actions";
|
|
1381
|
+
headerActions.append(cancel);
|
|
1382
|
+
header.append(title, headerActions);
|
|
1383
|
+
header.addEventListener("click", (event) => {
|
|
1384
|
+
if (event.target instanceof Element && event.target.closest("button")) {
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
toggleCollapsed();
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
let initialFocus: HTMLElement = cancel;
|
|
1392
|
+
|
|
1393
|
+
if (request.method === "select") {
|
|
1394
|
+
const choices = document.createElement("div");
|
|
1395
|
+
choices.className = "extension-prompt-choices";
|
|
1396
|
+
for (const option of request.options) {
|
|
1397
|
+
const button = textElement("button", "secondary", option) as HTMLButtonElement;
|
|
1398
|
+
button.type = "button";
|
|
1399
|
+
button.addEventListener("click", () => void respondToExtensionUI(request, { value: option }));
|
|
1400
|
+
choices.append(button);
|
|
1401
|
+
if (choices.childElementCount === 1) {
|
|
1402
|
+
initialFocus = button;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
body.append(choices);
|
|
1406
|
+
} else if (request.method === "confirm") {
|
|
1407
|
+
body.append(textElement("p", "message-text", request.message));
|
|
1408
|
+
const confirm = textElement("button", "", "Confirm") as HTMLButtonElement;
|
|
1409
|
+
confirm.type = "button";
|
|
1410
|
+
confirm.addEventListener("click", () => void respondToExtensionUI(request, { confirmed: true }));
|
|
1411
|
+
actions.append(confirm);
|
|
1412
|
+
initialFocus = confirm;
|
|
1413
|
+
} else {
|
|
1414
|
+
const field = request.method === "editor" ? document.createElement("textarea") : document.createElement("input");
|
|
1415
|
+
field.className = "extension-prompt-field";
|
|
1416
|
+
if (field instanceof HTMLTextAreaElement) {
|
|
1417
|
+
field.rows = 8;
|
|
1418
|
+
field.value = "prefill" in request ? (request.prefill ?? "") : "";
|
|
1419
|
+
} else {
|
|
1420
|
+
field.type = "text";
|
|
1421
|
+
field.placeholder = "placeholder" in request ? (request.placeholder ?? "") : "";
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const submit = textElement("button", "", "Submit") as HTMLButtonElement;
|
|
1425
|
+
submit.type = "button";
|
|
1426
|
+
submit.addEventListener("click", () => void respondToExtensionUI(request, { value: field.value }));
|
|
1427
|
+
field.addEventListener("keydown", (event) => {
|
|
1428
|
+
const keyboardEvent = event as KeyboardEvent;
|
|
1429
|
+
if (
|
|
1430
|
+
keyboardEvent.key === "Enter" &&
|
|
1431
|
+
(request.method === "input" || keyboardEvent.ctrlKey || keyboardEvent.metaKey)
|
|
1432
|
+
) {
|
|
1433
|
+
keyboardEvent.preventDefault();
|
|
1434
|
+
void respondToExtensionUI(request, { value: field.value });
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
body.append(field);
|
|
1438
|
+
actions.append(submit);
|
|
1439
|
+
initialFocus = field;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
panel.append(header, body, actions);
|
|
1443
|
+
elements.extensionPrompt.replaceChildren(panel);
|
|
1444
|
+
elements.extensionPrompt.hidden = false;
|
|
1445
|
+
queueMicrotask(() => initialFocus.focus());
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function applyExtensionUIState(state: ExtensionUIState | undefined): void {
|
|
1449
|
+
extensionUI = cloneExtensionUIState(state);
|
|
1450
|
+
if (currentRuntime) {
|
|
1451
|
+
currentRuntime = { ...currentRuntime, extensionUI: cloneExtensionUIState(extensionUI) };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
renderExtensionState();
|
|
1455
|
+
renderExtensionPrompt();
|
|
1456
|
+
updateControls();
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function handleExtensionUIRequest(request: ExtensionUIRequest): void {
|
|
1460
|
+
if (
|
|
1461
|
+
request.method === "select" ||
|
|
1462
|
+
request.method === "confirm" ||
|
|
1463
|
+
request.method === "input" ||
|
|
1464
|
+
request.method === "editor"
|
|
1465
|
+
) {
|
|
1466
|
+
applyExtensionUIState({
|
|
1467
|
+
...extensionUI,
|
|
1468
|
+
pending: [...extensionUI.pending.filter((candidate) => candidate.id !== request.id), request],
|
|
1469
|
+
});
|
|
1470
|
+
void reconcileSession();
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (request.method === "notify") {
|
|
1475
|
+
const notification = createMessage(
|
|
1476
|
+
{ role: "extension", content: request.message, isError: request.notifyType === "error" },
|
|
1477
|
+
`extension · ${request.notifyType ?? "info"}`,
|
|
1478
|
+
);
|
|
1479
|
+
elements.transcript.querySelector(".empty-state")?.remove();
|
|
1480
|
+
insertBeforePendingQueue(notification);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
if (request.method === "setStatus") {
|
|
1485
|
+
const statuses = extensionUI.statuses.filter((status) => status.key !== request.statusKey);
|
|
1486
|
+
if (request.statusText !== undefined) {
|
|
1487
|
+
statuses.push({ key: request.statusKey, text: request.statusText });
|
|
1488
|
+
}
|
|
1489
|
+
applyExtensionUIState({ ...extensionUI, statuses });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (request.method === "setWidget") {
|
|
1494
|
+
const widgets = extensionUI.widgets.filter((widget) => widget.key !== request.widgetKey);
|
|
1495
|
+
if (request.widgetLines !== undefined) {
|
|
1496
|
+
widgets.push({
|
|
1497
|
+
key: request.widgetKey,
|
|
1498
|
+
lines: [...request.widgetLines],
|
|
1499
|
+
placement: request.widgetPlacement ?? "aboveEditor",
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
applyExtensionUIState({ ...extensionUI, widgets });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (request.method === "setTitle") {
|
|
1507
|
+
document.title = request.title;
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
setPromptValue(request.text);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function applyRuntimeState(runtime: RuntimeState | null): void {
|
|
1515
|
+
const previousQueue = copyMessageQueue(currentRuntime?.queue);
|
|
1516
|
+
const nextRuntime = runtime ? { ...runtime, queue: copyMessageQueue(runtime.queue) } : null;
|
|
1517
|
+
if (!nextRuntime) {
|
|
1518
|
+
resetStreamBlocks();
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const submittedQueueIndex =
|
|
1522
|
+
optimisticSubmission && nextRuntime && steeringQueueGrew(previousQueue, nextRuntime.queue)
|
|
1523
|
+
? previousQueue.steering.length
|
|
1524
|
+
: undefined;
|
|
1525
|
+
if (submittedQueueIndex !== undefined) {
|
|
1526
|
+
optimisticSubmission?.root.remove();
|
|
1527
|
+
optimisticSubmission = undefined;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
currentRuntime = nextRuntime;
|
|
1531
|
+
if (nextRuntime && currentSession) {
|
|
1532
|
+
if (nextRuntime.sessionName) {
|
|
1533
|
+
currentSession.name = nextRuntime.sessionName;
|
|
1534
|
+
} else {
|
|
1535
|
+
delete currentSession.name;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
applyExtensionUIState(nextRuntime?.extensionUI);
|
|
1539
|
+
renderPendingQueue();
|
|
1540
|
+
if (submittedQueueIndex !== undefined) {
|
|
1541
|
+
anchorPendingSteeringMessage(submittedQueueIndex);
|
|
1542
|
+
}
|
|
1543
|
+
updateControls();
|
|
1544
|
+
updateSessionContext();
|
|
1545
|
+
updateSessionHeading();
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function patchRuntimeState(patch: Partial<Omit<RuntimeState, "extensionUI">>): void {
|
|
1549
|
+
const existing = currentRuntime ?? emptyRuntime();
|
|
1550
|
+
currentRuntime = {
|
|
1551
|
+
...existing,
|
|
1552
|
+
...patch,
|
|
1553
|
+
queue: copyMessageQueue(patch.queue ?? existing.queue),
|
|
1554
|
+
extensionUI: cloneExtensionUIState(extensionUI),
|
|
1555
|
+
};
|
|
1556
|
+
renderPendingQueue();
|
|
1557
|
+
updateControls();
|
|
1558
|
+
updateSessionContext();
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async function reconcileSession(): Promise<void> {
|
|
1562
|
+
if (!currentSessionId) {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const selectedId = currentSessionId;
|
|
1566
|
+
const sequence = ++reconcileSequence;
|
|
1567
|
+
|
|
1568
|
+
try {
|
|
1569
|
+
const response = await api<SessionResponse>(`/api/sessions/${encodeURIComponent(selectedId)}`);
|
|
1570
|
+
if (currentSessionId !== selectedId || sequence !== reconcileSequence) {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
currentSession = response.session;
|
|
1575
|
+
applyRuntimeState(response.runtime);
|
|
1576
|
+
updateSessionHeading();
|
|
1577
|
+
setWorkspace(response.session.cwd, response.session.gitContext);
|
|
1578
|
+
optimisticSubmission = undefined;
|
|
1579
|
+
dequeuedSteering = [];
|
|
1580
|
+
renderConversation(response.session, currentRuntime);
|
|
1581
|
+
|
|
1582
|
+
const latestUserEntry = [...response.session.transcriptEntries]
|
|
1583
|
+
.reverse()
|
|
1584
|
+
.find(
|
|
1585
|
+
(entry): entry is Extract<PersistedTranscriptEntry, { kind: "message" }> =>
|
|
1586
|
+
entry.kind === "message" && entry.message.role === "user",
|
|
1587
|
+
);
|
|
1588
|
+
if (
|
|
1589
|
+
submittedTurnAnchor?.sessionId === selectedId &&
|
|
1590
|
+
latestUserEntry &&
|
|
1591
|
+
messageText(latestUserEntry.message) === submittedTurnAnchor.message
|
|
1592
|
+
) {
|
|
1593
|
+
const renderedUsers = elements.transcript.querySelectorAll<HTMLElement>(".message.user:not(.pending)");
|
|
1594
|
+
const renderedUser = renderedUsers[renderedUsers.length - 1];
|
|
1595
|
+
if (renderedUser) {
|
|
1596
|
+
alignSubmittedTurn(renderedUser);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
if (currentSessionId === selectedId && sequence === reconcileSequence) {
|
|
1601
|
+
appendError(readableError(error));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function handleAgentEvent(event: Record<string, unknown>): void {
|
|
1607
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
1608
|
+
|
|
1609
|
+
if (type === "agent_start") {
|
|
1610
|
+
patchRuntimeState({ isStreaming: true, isWorking: true });
|
|
1611
|
+
ensureLiveActivity();
|
|
1612
|
+
} else if (type === "message_start") {
|
|
1613
|
+
const message = event.message as AgentMessage | undefined;
|
|
1614
|
+
if (message?.role === "assistant") {
|
|
1615
|
+
resetStreamBlocks();
|
|
1616
|
+
}
|
|
1617
|
+
} else if (type === "message_update") {
|
|
1618
|
+
handleMessageUpdate(event);
|
|
1619
|
+
} else if (type === "message_end") {
|
|
1620
|
+
const message = event.message as AgentMessage | undefined;
|
|
1621
|
+
if (message) {
|
|
1622
|
+
appendMessage(message);
|
|
1623
|
+
}
|
|
1624
|
+
} else if (type === "entry_appended") {
|
|
1625
|
+
const entry = event.entry as { type?: unknown } | undefined;
|
|
1626
|
+
if (entry?.type === "custom" || entry?.type === "custom_message") {
|
|
1627
|
+
void reconcileSession();
|
|
1628
|
+
}
|
|
1629
|
+
} else if (type === "tool_execution_start") {
|
|
1630
|
+
updateTool(event, false);
|
|
1631
|
+
patchRuntimeState({ isWorking: true });
|
|
1632
|
+
} else if (type === "tool_execution_update") {
|
|
1633
|
+
updateTool(event, false);
|
|
1634
|
+
} else if (type === "tool_execution_end") {
|
|
1635
|
+
updateTool(event, true);
|
|
1636
|
+
} else if (type === "queue_update") {
|
|
1637
|
+
const previousQueue = copyMessageQueue(currentRuntime?.queue);
|
|
1638
|
+
const update = reconcileMessageQueue(previousQueue, messageQueueFromEvent(event), discardingQueue);
|
|
1639
|
+
const pendingMessageCount = update.queue.steering.length + update.queue.followUp.length;
|
|
1640
|
+
const submittedQueueIndex =
|
|
1641
|
+
optimisticSubmission && steeringQueueGrew(previousQueue, update.queue)
|
|
1642
|
+
? previousQueue.steering.length
|
|
1643
|
+
: undefined;
|
|
1644
|
+
if (discardingQueue) {
|
|
1645
|
+
dequeuedSteering = [];
|
|
1646
|
+
}
|
|
1647
|
+
dequeuedSteering.push(...update.dequeuedSteering);
|
|
1648
|
+
if (submittedQueueIndex !== undefined) {
|
|
1649
|
+
optimisticSubmission?.root.remove();
|
|
1650
|
+
optimisticSubmission = undefined;
|
|
1651
|
+
}
|
|
1652
|
+
patchRuntimeState({
|
|
1653
|
+
queue: update.queue,
|
|
1654
|
+
pendingMessageCount,
|
|
1655
|
+
...(pendingMessageCount > 0 ? { isWorking: true } : {}),
|
|
1656
|
+
});
|
|
1657
|
+
if (submittedQueueIndex !== undefined) {
|
|
1658
|
+
anchorPendingSteeringMessage(submittedQueueIndex);
|
|
1659
|
+
}
|
|
1660
|
+
} else if (type === "compaction_start" || type === "auto_retry_start") {
|
|
1661
|
+
patchRuntimeState({ isCompacting: type === "compaction_start", isWorking: true });
|
|
1662
|
+
ensureLiveActivity();
|
|
1663
|
+
} else if (type === "session_info_changed") {
|
|
1664
|
+
const name = typeof event.name === "string" && event.name.trim() ? event.name.trim() : undefined;
|
|
1665
|
+
applyCurrentSessionName(name);
|
|
1666
|
+
} else if (type === "agent_settled") {
|
|
1667
|
+
dequeuedSteering = [];
|
|
1668
|
+
patchRuntimeState({
|
|
1669
|
+
isStreaming: false,
|
|
1670
|
+
isCompacting: false,
|
|
1671
|
+
isWorking: false,
|
|
1672
|
+
pendingMessageCount: 0,
|
|
1673
|
+
queue: { steering: [], followUp: [] },
|
|
1674
|
+
});
|
|
1675
|
+
if (liveActivity) {
|
|
1676
|
+
if (liveActivity.root.dataset.state !== "aborted") {
|
|
1677
|
+
setActivityRunning(liveActivity, false);
|
|
1678
|
+
}
|
|
1679
|
+
collapseActivity(liveActivity);
|
|
1680
|
+
}
|
|
1681
|
+
void reconcileSession();
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function emptyRuntime(): RuntimeState {
|
|
1686
|
+
return {
|
|
1687
|
+
id: currentSessionId ?? "",
|
|
1688
|
+
cwd: currentSession?.cwd ?? "",
|
|
1689
|
+
isStreaming: false,
|
|
1690
|
+
isCompacting: false,
|
|
1691
|
+
isWorking: false,
|
|
1692
|
+
pendingMessageCount: 0,
|
|
1693
|
+
queue: { steering: [], followUp: [] },
|
|
1694
|
+
model: currentSession?.model
|
|
1695
|
+
? {
|
|
1696
|
+
provider: currentSession.model.provider,
|
|
1697
|
+
id: currentSession.model.modelId,
|
|
1698
|
+
name: currentSession.model.modelId,
|
|
1699
|
+
}
|
|
1700
|
+
: null,
|
|
1701
|
+
thinkingLevel: currentSession?.thinkingLevel ?? "off",
|
|
1702
|
+
autoCompactionEnabled: false,
|
|
1703
|
+
usage: {
|
|
1704
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
1705
|
+
context: null,
|
|
1706
|
+
},
|
|
1707
|
+
commands: [],
|
|
1708
|
+
extensionUI,
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function handleRuntimeEnvelope(envelope: RuntimeEnvelope): void {
|
|
1713
|
+
if (envelope.type === "agent_event") {
|
|
1714
|
+
handleAgentEvent(envelope.event);
|
|
1715
|
+
}
|
|
1716
|
+
if (envelope.type === "extension_ui_request") {
|
|
1717
|
+
handleExtensionUIRequest(envelope);
|
|
1718
|
+
}
|
|
1719
|
+
if (envelope.type === "extension_ui_closed") {
|
|
1720
|
+
applyExtensionUIState({
|
|
1721
|
+
...extensionUI,
|
|
1722
|
+
pending: extensionUI.pending.filter((request) => request.id !== envelope.id),
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
if (envelope.type === "extension_ui_reset") {
|
|
1726
|
+
applyExtensionUIState(undefined);
|
|
1727
|
+
}
|
|
1728
|
+
if (envelope.type === "session_replaced" && envelope.sessionId !== currentSessionId) {
|
|
1729
|
+
void selectSession(envelope.sessionId);
|
|
1730
|
+
}
|
|
1731
|
+
if (envelope.type === "runtime_error") {
|
|
1732
|
+
appendError(envelope.message);
|
|
1733
|
+
void reconcileSession();
|
|
1734
|
+
}
|
|
1735
|
+
if (envelope.type === "runtime_disposed") {
|
|
1736
|
+
dequeuedSteering = [];
|
|
1737
|
+
resetStreamBlocks();
|
|
1738
|
+
patchRuntimeState({ pendingMessageCount: 0, queue: { steering: [], followUp: [] } });
|
|
1739
|
+
eventStream.reconnectNow();
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function renderConnectionState(state: EventStreamState): void {
|
|
1744
|
+
if (state === "connected") {
|
|
1745
|
+
setConnection("Connected", "online");
|
|
1746
|
+
} else if (state === "connecting") {
|
|
1747
|
+
setConnection("Connecting", "offline");
|
|
1748
|
+
} else {
|
|
1749
|
+
setConnection("Reconnecting", "error");
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const eventStream = new SessionEventStream({
|
|
1754
|
+
onStateChange: renderConnectionState,
|
|
1755
|
+
onReady: (event, recovered) => {
|
|
1756
|
+
if (!currentSessionId) {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
try {
|
|
1761
|
+
const ready = JSON.parse(event.data) as { gap: boolean; state: RuntimeState };
|
|
1762
|
+
applyRuntimeState(ready.state);
|
|
1763
|
+
if (ready.state.isWorking) {
|
|
1764
|
+
setActivityRunning(ensureLiveActivity(), true);
|
|
1765
|
+
} else if (liveActivity) {
|
|
1766
|
+
if (liveActivity.root.dataset.state !== "aborted") {
|
|
1767
|
+
setActivityRunning(liveActivity, false);
|
|
1768
|
+
}
|
|
1769
|
+
collapseActivity(liveActivity);
|
|
1770
|
+
}
|
|
1771
|
+
if (ready.state.streamingMessage && !liveMessage && streamBlocks.size === 0) {
|
|
1772
|
+
hydrateStreamingMessage(ready.state.streamingMessage);
|
|
1773
|
+
}
|
|
1774
|
+
if (ready.gap || recovered) {
|
|
1775
|
+
void reconcileSession();
|
|
1776
|
+
}
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
appendError(`Could not read agent state: ${readableError(error)}`);
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
onRuntime: (event) => {
|
|
1782
|
+
if (!currentSessionId) {
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
try {
|
|
1787
|
+
handleRuntimeEnvelope(JSON.parse(event.data) as RuntimeEnvelope);
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
appendError(`Could not read agent event: ${readableError(error)}`);
|
|
1790
|
+
}
|
|
1791
|
+
},
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
function connectEvents(id: string): void {
|
|
1795
|
+
eventStream.start(id);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
async function selectSession(id: string, updateHistory = true): Promise<void> {
|
|
1799
|
+
if (id === currentSessionId && currentSession) {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
eventStream.stop();
|
|
1804
|
+
setSubmittedTurnAnchor(false);
|
|
1805
|
+
currentSessionId = id;
|
|
1806
|
+
currentSession = undefined;
|
|
1807
|
+
elements.createSession.href = `${sessionPath(id)}/new`;
|
|
1808
|
+
currentRuntime = null;
|
|
1809
|
+
applyExtensionUIState(undefined);
|
|
1810
|
+
optimisticSubmission = undefined;
|
|
1811
|
+
submittedTurnAnchor = undefined;
|
|
1812
|
+
dequeuedSteering = [];
|
|
1813
|
+
discardingQueue = false;
|
|
1814
|
+
liveMessage = undefined;
|
|
1815
|
+
liveActivity = undefined;
|
|
1816
|
+
resetStreamBlocks();
|
|
1817
|
+
liveTools.clear();
|
|
1818
|
+
sending = false;
|
|
1819
|
+
renaming = false;
|
|
1820
|
+
if (updateHistory) {
|
|
1821
|
+
history.pushState(null, "", sessionPath(id));
|
|
1822
|
+
}
|
|
1823
|
+
setPromptValue("");
|
|
1824
|
+
elements.sessionTitle.textContent = "Loading…";
|
|
1825
|
+
elements.sessionTitle.title = "Session title is loading";
|
|
1826
|
+
elements.sessionTitle.setAttribute("aria-label", "Session title is loading");
|
|
1827
|
+
setWorkspace("", undefined);
|
|
1828
|
+
setContextValue(elements.sessionModel, "");
|
|
1829
|
+
elements.sessionModel.setAttribute("aria-label", "Model and thinking level are loading.");
|
|
1830
|
+
updateSessionUsage(null);
|
|
1831
|
+
renderEmpty("Loading conversation", "Reading the saved Pi session…");
|
|
1832
|
+
updateControls();
|
|
1833
|
+
|
|
1834
|
+
try {
|
|
1835
|
+
const response = await api<SessionResponse>(`/api/sessions/${encodeURIComponent(id)}`);
|
|
1836
|
+
if (currentSessionId !== id) {
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
currentSession = response.session;
|
|
1841
|
+
applyRuntimeState(response.runtime);
|
|
1842
|
+
updateSessionHeading();
|
|
1843
|
+
setWorkspace(response.session.cwd, response.session.gitContext);
|
|
1844
|
+
renderConversation(response.session, response.runtime);
|
|
1845
|
+
connectEvents(id);
|
|
1846
|
+
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
|
|
1847
|
+
elements.prompt.focus();
|
|
1848
|
+
}
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
if (currentSessionId !== id) {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
appendError(readableError(error));
|
|
1854
|
+
setConnection("Unavailable", "error");
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function renameCurrentSession(): Promise<void> {
|
|
1859
|
+
if (!currentSessionId || renaming) {
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const value = window.prompt("Session title (leave blank to clear)", currentNativeName() ?? "");
|
|
1864
|
+
if (value === null) {
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const id = currentSessionId;
|
|
1869
|
+
renaming = true;
|
|
1870
|
+
updateControls();
|
|
1871
|
+
|
|
1872
|
+
try {
|
|
1873
|
+
const response = await api<{ name: string | null; state: RuntimeState }>(
|
|
1874
|
+
`/api/sessions/${encodeURIComponent(id)}/name`,
|
|
1875
|
+
{
|
|
1876
|
+
method: "PUT",
|
|
1877
|
+
headers: { "content-type": "application/json" },
|
|
1878
|
+
body: JSON.stringify({ name: value }),
|
|
1879
|
+
},
|
|
1880
|
+
);
|
|
1881
|
+
if (currentSessionId !== id) {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
applyRuntimeState(response.state);
|
|
1886
|
+
applyCurrentSessionName(response.name ?? undefined);
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
if (currentSessionId === id) {
|
|
1889
|
+
appendError(`Could not rename session: ${readableError(error)}`);
|
|
1890
|
+
}
|
|
1891
|
+
} finally {
|
|
1892
|
+
renaming = false;
|
|
1893
|
+
updateControls();
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
async function sendMessage(): Promise<void> {
|
|
1898
|
+
if (!currentSessionId || sending) {
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const message = elements.prompt.value;
|
|
1902
|
+
if (!message.trim()) {
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const id = currentSessionId;
|
|
1907
|
+
const pendingExtensionRequest = extensionUI.pending[0];
|
|
1908
|
+
const previousFirstMessage = currentSession?.firstMessage;
|
|
1909
|
+
const shouldUpdateFirstMessage = Boolean(currentSession && !currentSession.firstMessage.trim());
|
|
1910
|
+
if (currentSession && shouldUpdateFirstMessage) {
|
|
1911
|
+
currentSession.firstMessage = message;
|
|
1912
|
+
updateSessionHeading();
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const activatedTurnAnchor = !elements.transcript.classList.contains("has-submitted-turn-anchor");
|
|
1916
|
+
const previousSubmittedTurnAnchor = submittedTurnAnchor;
|
|
1917
|
+
setSubmittedTurnAnchor(true);
|
|
1918
|
+
elements.transcript.querySelector(".empty-state")?.remove();
|
|
1919
|
+
if (!currentRuntime?.isWorking) {
|
|
1920
|
+
liveActivity = undefined;
|
|
1921
|
+
resetStreamBlocks();
|
|
1922
|
+
}
|
|
1923
|
+
const optimistic = createMessage({ role: "user", content: message });
|
|
1924
|
+
optimistic.dataset.optimistic = "true";
|
|
1925
|
+
optimisticSubmission = { sessionId: id, message, root: optimistic };
|
|
1926
|
+
submittedTurnAnchor = { sessionId: id, message, root: optimistic, needsAlignment: true };
|
|
1927
|
+
elements.transcript.append(optimistic);
|
|
1928
|
+
updateSubmittedTurnRunway();
|
|
1929
|
+
optimistic.scrollIntoView({ block: "start" });
|
|
1930
|
+
|
|
1931
|
+
setPromptValue("");
|
|
1932
|
+
sending = true;
|
|
1933
|
+
updateControls();
|
|
1934
|
+
eventStream.ensureConnected();
|
|
1935
|
+
|
|
1936
|
+
try {
|
|
1937
|
+
if (pendingExtensionRequest) {
|
|
1938
|
+
await respondToExtensionUI(pendingExtensionRequest, { cancelled: true }, false);
|
|
1939
|
+
if (currentSessionId !== id) {
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const response = await api<{ queued: boolean; state: RuntimeState }>(
|
|
1945
|
+
`/api/sessions/${encodeURIComponent(id)}/messages`,
|
|
1946
|
+
{
|
|
1947
|
+
method: "POST",
|
|
1948
|
+
headers: { "content-type": "application/json" },
|
|
1949
|
+
body: JSON.stringify({ message }),
|
|
1950
|
+
},
|
|
1951
|
+
);
|
|
1952
|
+
if (currentSessionId !== id) {
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
applyRuntimeState(response.state);
|
|
1956
|
+
if (response.state.isWorking) {
|
|
1957
|
+
ensureLiveActivity();
|
|
1958
|
+
}
|
|
1959
|
+
void reconcileSession();
|
|
1960
|
+
} catch (error) {
|
|
1961
|
+
optimistic.remove();
|
|
1962
|
+
if (optimisticSubmission?.root === optimistic) {
|
|
1963
|
+
optimisticSubmission = undefined;
|
|
1964
|
+
}
|
|
1965
|
+
if (currentSessionId === id) {
|
|
1966
|
+
submittedTurnAnchor = previousSubmittedTurnAnchor;
|
|
1967
|
+
if (activatedTurnAnchor) {
|
|
1968
|
+
setSubmittedTurnAnchor(false);
|
|
1969
|
+
} else {
|
|
1970
|
+
updateSubmittedTurnRunway();
|
|
1971
|
+
}
|
|
1972
|
+
if (currentSession && shouldUpdateFirstMessage) {
|
|
1973
|
+
currentSession.firstMessage = previousFirstMessage ?? "";
|
|
1974
|
+
updateSessionHeading();
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
setPromptValue(message);
|
|
1978
|
+
appendError(readableError(error));
|
|
1979
|
+
void reconcileSession();
|
|
1980
|
+
}
|
|
1981
|
+
} finally {
|
|
1982
|
+
sending = false;
|
|
1983
|
+
updateControls();
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
async function removePendingSteering(index: number, message: string): Promise<void> {
|
|
1988
|
+
if (!currentSessionId || !currentRuntime || sending) {
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
const id = currentSessionId;
|
|
1993
|
+
const expectedQueue = copyMessageQueue(currentRuntime.queue);
|
|
1994
|
+
const nextQueue = withoutSteeringMessage(expectedQueue, index, message);
|
|
1995
|
+
if (!nextQueue) {
|
|
1996
|
+
void reconcileSession();
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
sending = true;
|
|
2000
|
+
discardingQueue = true;
|
|
2001
|
+
dequeuedSteering = [];
|
|
2002
|
+
patchRuntimeState({
|
|
2003
|
+
pendingMessageCount: nextQueue.steering.length + nextQueue.followUp.length,
|
|
2004
|
+
queue: nextQueue,
|
|
2005
|
+
});
|
|
2006
|
+
updateControls();
|
|
2007
|
+
|
|
2008
|
+
try {
|
|
2009
|
+
const response = await api<{ state: RuntimeState }>(`/api/sessions/${encodeURIComponent(id)}/pending-steering`, {
|
|
2010
|
+
method: "DELETE",
|
|
2011
|
+
headers: { "content-type": "application/json" },
|
|
2012
|
+
body: JSON.stringify({ index, message, queue: expectedQueue.steering }),
|
|
2013
|
+
});
|
|
2014
|
+
if (currentSessionId !== id) {
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
applyRuntimeState(response.state);
|
|
2019
|
+
await reconcileSession();
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
if (currentSessionId === id) {
|
|
2022
|
+
appendError(`Could not remove pending message: ${readableError(error)}`);
|
|
2023
|
+
await reconcileSession();
|
|
2024
|
+
}
|
|
2025
|
+
} finally {
|
|
2026
|
+
sending = false;
|
|
2027
|
+
discardingQueue = false;
|
|
2028
|
+
renderPendingQueue();
|
|
2029
|
+
updateControls();
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
async function abortRun(): Promise<void> {
|
|
2034
|
+
if (!currentSessionId || sending) {
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const id = currentSessionId;
|
|
2038
|
+
sending = true;
|
|
2039
|
+
discardingQueue = true;
|
|
2040
|
+
dequeuedSteering = [];
|
|
2041
|
+
patchRuntimeState({ pendingMessageCount: 0, queue: { steering: [], followUp: [] } });
|
|
2042
|
+
updateControls();
|
|
2043
|
+
|
|
2044
|
+
try {
|
|
2045
|
+
const response = await api<{ restoredMessages: string[] }>(`/api/sessions/${encodeURIComponent(id)}/abort`, {
|
|
2046
|
+
method: "POST",
|
|
2047
|
+
headers: { "content-type": "application/json" },
|
|
2048
|
+
body: "{}",
|
|
2049
|
+
});
|
|
2050
|
+
if (currentSessionId !== id) {
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (response.restoredMessages.length > 0) {
|
|
2054
|
+
setPromptValue(response.restoredMessages.join("\n\n"));
|
|
2055
|
+
}
|
|
2056
|
+
if (liveActivity) {
|
|
2057
|
+
setActivityAborted(liveActivity);
|
|
2058
|
+
}
|
|
2059
|
+
await reconcileSession();
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
if (currentSessionId === id) {
|
|
2062
|
+
appendError(readableError(error));
|
|
2063
|
+
void reconcileSession();
|
|
2064
|
+
}
|
|
2065
|
+
} finally {
|
|
2066
|
+
sending = false;
|
|
2067
|
+
discardingQueue = false;
|
|
2068
|
+
updateControls();
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const submittedTurnRunwayObserver = new MutationObserver(scheduleSubmittedTurnRunwayUpdate);
|
|
2073
|
+
submittedTurnRunwayObserver.observe(elements.transcript, {
|
|
2074
|
+
attributeFilter: ["open"],
|
|
2075
|
+
attributes: true,
|
|
2076
|
+
characterData: true,
|
|
2077
|
+
childList: true,
|
|
2078
|
+
subtree: true,
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
elements.composer.addEventListener("submit", (event) => {
|
|
2082
|
+
event.preventDefault();
|
|
2083
|
+
void sendMessage();
|
|
2084
|
+
});
|
|
2085
|
+
elements.prompt.addEventListener("input", () => {
|
|
2086
|
+
dismissedSlashCompletionValue = undefined;
|
|
2087
|
+
resizePrompt();
|
|
2088
|
+
renderSlashCompletion(true);
|
|
2089
|
+
});
|
|
2090
|
+
elements.prompt.addEventListener("keydown", (event) => {
|
|
2091
|
+
if (slashCompletion && !event.isComposing) {
|
|
2092
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2093
|
+
event.preventDefault();
|
|
2094
|
+
slashCompletion = moveSlashCompletionSelection(slashCompletion, event.key === "ArrowDown" ? 1 : -1);
|
|
2095
|
+
renderSlashCompletion();
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
if (event.key === "Tab") {
|
|
2100
|
+
event.preventDefault();
|
|
2101
|
+
applySelectedSlashCompletion(false);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
2106
|
+
event.preventDefault();
|
|
2107
|
+
applySelectedSlashCompletion(true);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
if (event.key === "Escape") {
|
|
2112
|
+
event.preventDefault();
|
|
2113
|
+
closeSlashCompletion(true);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
|
2119
|
+
event.preventDefault();
|
|
2120
|
+
void sendMessage();
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
elements.abort.addEventListener("click", () => void abortRun());
|
|
2124
|
+
elements.sessionTitle.addEventListener("click", () => void renameCurrentSession());
|
|
2125
|
+
elements.sessionContextToggle.addEventListener("click", () => {
|
|
2126
|
+
setSessionContextCollapsed(!elements.sessionContext.classList.contains("is-collapsed"));
|
|
2127
|
+
});
|
|
2128
|
+
window.addEventListener("popstate", () => {
|
|
2129
|
+
const route = routeFromLocation();
|
|
2130
|
+
if (route) {
|
|
2131
|
+
void selectSession(route, false);
|
|
2132
|
+
} else {
|
|
2133
|
+
location.reload();
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
document.addEventListener("visibilitychange", () => {
|
|
2137
|
+
if (document.visibilityState !== "visible" || !currentSessionId) {
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
eventStream.ensureConnected();
|
|
2142
|
+
void reconcileSession();
|
|
2143
|
+
});
|
|
2144
|
+
window.addEventListener("online", () => {
|
|
2145
|
+
if (!currentSessionId) {
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
eventStream.reconnectNow();
|
|
2150
|
+
void reconcileSession();
|
|
2151
|
+
});
|
|
2152
|
+
window.addEventListener("resize", () => {
|
|
2153
|
+
resizePrompt();
|
|
2154
|
+
scheduleSubmittedTurnRunwayUpdate();
|
|
2155
|
+
});
|
|
2156
|
+
function routeFromLocation(): string | undefined {
|
|
2157
|
+
const match = /^\/sessions\/([^/]+)\/?$/.exec(location.pathname);
|
|
2158
|
+
if (!match?.[1]) {
|
|
2159
|
+
return undefined;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
try {
|
|
2163
|
+
return decodeURIComponent(match[1]);
|
|
2164
|
+
} catch {
|
|
2165
|
+
return undefined;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
const requestedSession = routeFromLocation();
|
|
2170
|
+
if (requestedSession) {
|
|
2171
|
+
void selectSession(requestedSession, false);
|
|
2172
|
+
} else {
|
|
2173
|
+
location.assign("/");
|
|
2174
|
+
}
|