@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.
Files changed (43) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +50 -0
  3. package/THIRD_PARTY_LICENSES.md +40 -0
  4. package/dist/client/home.js +1619 -0
  5. package/dist/client/session.js +3703 -0
  6. package/dist/server/api.js +485 -0
  7. package/dist/server/cli.js +51 -0
  8. package/dist/server/directory-browser.js +104 -0
  9. package/dist/server/errors.js +10 -0
  10. package/dist/server/event-buffer.js +40 -0
  11. package/dist/server/extension-ui.js +245 -0
  12. package/dist/server/git-workspaces.js +559 -0
  13. package/dist/server/runtime-registry.js +703 -0
  14. package/dist/server/server.js +190 -0
  15. package/dist/server/session-repository.js +374 -0
  16. package/package.json +46 -0
  17. package/public/home.html +139 -0
  18. package/public/session.html +144 -0
  19. package/public/styles.css +2463 -0
  20. package/screenshots/home.png +0 -0
  21. package/screenshots/session.png +0 -0
  22. package/src/client/display-title.ts +36 -0
  23. package/src/client/event-stream.ts +194 -0
  24. package/src/client/home.ts +1575 -0
  25. package/src/client/markdown.ts +98 -0
  26. package/src/client/message-queue.ts +67 -0
  27. package/src/client/path-combobox.ts +271 -0
  28. package/src/client/session.ts +2174 -0
  29. package/src/client/shared.ts +99 -0
  30. package/src/client/slash-completion.ts +184 -0
  31. package/src/client/transcript-activity.ts +188 -0
  32. package/src/client/usage-format.ts +156 -0
  33. package/src/client/workspace-browser.ts +36 -0
  34. package/src/server/api.ts +652 -0
  35. package/src/server/cli.ts +63 -0
  36. package/src/server/directory-browser.ts +137 -0
  37. package/src/server/errors.ts +11 -0
  38. package/src/server/event-buffer.ts +59 -0
  39. package/src/server/extension-ui.ts +359 -0
  40. package/src/server/git-workspaces.ts +750 -0
  41. package/src/server/runtime-registry.ts +943 -0
  42. package/src/server/server.ts +248 -0
  43. package/src/server/session-repository.ts +488 -0
@@ -0,0 +1,1575 @@
1
+ import { displaySessionTitle } from "./display-title.js";
2
+ import { PathCombobox, type PathComboboxSuggestion } from "./path-combobox.js";
3
+ import {
4
+ api,
5
+ type GitWorkspaceInspection,
6
+ type GitWorktreeSummary,
7
+ readableError,
8
+ requiredElement,
9
+ type RepositorySessionGroup,
10
+ type SessionListing,
11
+ type SessionSummary,
12
+ sessionPath,
13
+ textElement,
14
+ } from "./shared.js";
15
+ import { displayRelativePath, relativePathWithin, repositoryName, worktreeTargetPath } from "./workspace-browser.js";
16
+
17
+ const elements = {
18
+ appNotifications: requiredElement<HTMLElement>("[data-app-notifications]"),
19
+ checkoutFeedback: requiredElement<HTMLElement>("[data-workspace-checkout-feedback]"),
20
+ checkoutSelect: requiredElement<HTMLSelectElement>("[data-workspace-checkout]"),
21
+ createSubmit: requiredElement<HTMLButtonElement>("[data-create-submit]"),
22
+ gitStatus: requiredElement<HTMLElement>("[data-git-status]"),
23
+ kindRepository: requiredElement<HTMLInputElement>("[data-workspace-kind-repository]"),
24
+ kindStandalone: requiredElement<HTMLInputElement>("[data-workspace-kind-standalone]"),
25
+ latestSessionList: requiredElement<HTMLElement>("[data-latest-session-list]"),
26
+ newSession: requiredElement<HTMLFormElement>("[data-new-session]"),
27
+ newSessionCancel: requiredElement<HTMLButtonElement>("[data-new-session-cancel]"),
28
+ newSessionToggle: requiredElement<HTMLButtonElement>("[data-new-session-toggle]"),
29
+ newWorktreeMode: requiredElement<HTMLElement>("[data-new-worktree-mode]"),
30
+ recentWorkspace: requiredElement<HTMLSelectElement>("[data-recent-workspace]"),
31
+ relativeFeedback: requiredElement<HTMLElement>("[data-workspace-relative-feedback]"),
32
+ relativeInput: requiredElement<HTMLInputElement>("[data-workspace-relative-path]"),
33
+ relativeList: requiredElement<HTMLElement>("[data-workspace-relative-options]"),
34
+ relativeStatus: requiredElement<HTMLElement>("[data-workspace-relative-status]"),
35
+ relativeToggle: requiredElement<HTMLButtonElement>("[data-workspace-relative-toggle]"),
36
+ repositoryFeedback: requiredElement<HTMLElement>("[data-repository-path-feedback]"),
37
+ repositoryFields: requiredElement<HTMLElement>("[data-repository-fields]"),
38
+ repositoryInput: requiredElement<HTMLInputElement>("[data-repository-path]"),
39
+ repositoryList: requiredElement<HTMLElement>("[data-repository-path-options]"),
40
+ repositoryStatus: requiredElement<HTMLElement>("[data-repository-path-status]"),
41
+ repositoryToggle: requiredElement<HTMLButtonElement>("[data-repository-path-toggle]"),
42
+ sessionList: requiredElement<HTMLElement>("[data-session-list]"),
43
+ standaloneFeedback: requiredElement<HTMLElement>("[data-standalone-path-feedback]"),
44
+ standaloneFields: requiredElement<HTMLElement>("[data-standalone-fields]"),
45
+ standaloneInput: requiredElement<HTMLInputElement>("[data-standalone-path]"),
46
+ standaloneList: requiredElement<HTMLElement>("[data-standalone-path-options]"),
47
+ standaloneStatus: requiredElement<HTMLElement>("[data-standalone-path-status]"),
48
+ standaloneToggle: requiredElement<HTMLButtonElement>("[data-standalone-path-toggle]"),
49
+ worktreeAvailability: requiredElement<HTMLElement>("[data-worktree-availability]"),
50
+ worktreeCancel: requiredElement<HTMLButtonElement>("[data-worktree-cancel]"),
51
+ worktreeName: requiredElement<HTMLInputElement>("[data-worktree-name]"),
52
+ worktreeStart: requiredElement<HTMLButtonElement>("[data-worktree-start]"),
53
+ worktreeSubmit: requiredElement<HTMLButtonElement>("[data-worktree-submit]"),
54
+ worktreeTarget: requiredElement<HTMLElement>("[data-worktree-target]"),
55
+ };
56
+
57
+ type AppStatusTone = "error" | "success";
58
+ type WorkspaceKind = "repository" | "standalone";
59
+ type WorkspaceFeedback = "checkout" | "relative" | "repository" | "standalone";
60
+
61
+ interface AppNotificationRecord {
62
+ dismissal: number | undefined;
63
+ element: HTMLElement;
64
+ generation: number;
65
+ message: HTMLElement;
66
+ dismiss: HTMLButtonElement;
67
+ }
68
+
69
+ interface RepositorySelection {
70
+ repositoryRoot: string;
71
+ repositoryInspection: GitWorkspaceInspection;
72
+ selectedInspection: GitWorkspaceInspection;
73
+ worktreeRoot: string;
74
+ relativeCwd: string;
75
+ cwd: string;
76
+ }
77
+
78
+ interface StandaloneSelection {
79
+ inspection: GitWorkspaceInspection;
80
+ cwd: string;
81
+ }
82
+
83
+ interface DirectorySuggestionListing {
84
+ directories: Array<{ name: string; path: string }>;
85
+ truncated: boolean;
86
+ }
87
+
88
+ let listing: SessionListing = { groups: [], knownCwds: [], activeSessionIds: [], runningSessionIds: [] };
89
+ let workspaceKind: WorkspaceKind = "repository";
90
+ let repositorySelection: RepositorySelection | null = null;
91
+ let standaloneSelection: StandaloneSelection | null = null;
92
+ let inspectionGeneration = 0;
93
+ let workspacePending = false;
94
+ let pendingRecentWorkspaceValue: string | null = null;
95
+ let recentWorkspaceOptionsSignature = "";
96
+ let newSessionOpen = false;
97
+ let workspaceInitialized = false;
98
+ let newWorktreeOpen = false;
99
+ let creatingSession = false;
100
+ let contextualCreationCwd: string | null = null;
101
+ let repositoryWorktreeEditorRoot: string | null = null;
102
+ let repositoryWorktreeName = "";
103
+ let repositoryWorktreeCreationRoot: string | null = null;
104
+ const appNotifications = new Map<string, AppNotificationRecord>();
105
+ const deletingSessionIds = new Set<string>();
106
+ const removingWorktreeRoots = new Set<string>();
107
+
108
+ function formatTime(value: string): string {
109
+ const date = new Date(value);
110
+ if (Number.isNaN(date.getTime())) {
111
+ return "";
112
+ }
113
+
114
+ return new Intl.DateTimeFormat(undefined, {
115
+ month: "short",
116
+ day: "numeric",
117
+ hour: "numeric",
118
+ minute: "2-digit",
119
+ }).format(date);
120
+ }
121
+
122
+ function clearAppStatus(key: string): void {
123
+ const notification = appNotifications.get(key);
124
+ if (!notification) {
125
+ return;
126
+ }
127
+
128
+ if (notification.dismissal !== undefined) {
129
+ window.clearTimeout(notification.dismissal);
130
+ }
131
+ notification.element.remove();
132
+ appNotifications.delete(key);
133
+ elements.appNotifications.hidden = appNotifications.size === 0;
134
+ }
135
+
136
+ function createAppNotification(key: string): AppNotificationRecord {
137
+ const element = document.createElement("div");
138
+ element.className = "app-notification";
139
+
140
+ const message = document.createElement("p");
141
+ message.className = "app-notification-message";
142
+ message.setAttribute("aria-atomic", "true");
143
+
144
+ const dismiss = document.createElement("button");
145
+ dismiss.className = "app-notification-dismiss";
146
+ dismiss.type = "button";
147
+ dismiss.textContent = "×";
148
+ dismiss.setAttribute("aria-label", "Dismiss notification");
149
+ dismiss.addEventListener("click", () => clearAppStatus(key));
150
+
151
+ element.append(message, dismiss);
152
+ elements.appNotifications.prepend(element);
153
+ elements.appNotifications.hidden = false;
154
+
155
+ const notification = { dismissal: undefined, element, generation: 0, message, dismiss };
156
+ appNotifications.set(key, notification);
157
+ return notification;
158
+ }
159
+
160
+ function setAppStatus(key: string, message: string, tone: AppStatusTone): void {
161
+ if (!message) {
162
+ clearAppStatus(key);
163
+ return;
164
+ }
165
+
166
+ const notification = appNotifications.get(key) ?? createAppNotification(key);
167
+ const generation = ++notification.generation;
168
+ if (notification.dismissal !== undefined) {
169
+ window.clearTimeout(notification.dismissal);
170
+ notification.dismissal = undefined;
171
+ }
172
+
173
+ notification.element.dataset.tone = tone;
174
+ notification.dismiss.hidden = false;
175
+ notification.message.setAttribute("role", tone === "error" ? "alert" : "status");
176
+ notification.message.setAttribute("aria-live", tone === "error" ? "assertive" : "polite");
177
+ notification.message.textContent = message;
178
+
179
+ if (tone === "success") {
180
+ notification.dismissal = window.setTimeout(() => {
181
+ notification.dismissal = undefined;
182
+ if (generation === notification.generation) {
183
+ clearAppStatus(key);
184
+ }
185
+ }, 10_000);
186
+ }
187
+ }
188
+
189
+ function setGitStatus(message: string, isError = false): void {
190
+ elements.gitStatus.textContent = message;
191
+ elements.gitStatus.classList.toggle("error", isError);
192
+ }
193
+
194
+ function feedbackElement(control: WorkspaceFeedback): HTMLElement {
195
+ if (control === "checkout") {
196
+ return elements.checkoutFeedback;
197
+ }
198
+ if (control === "relative") {
199
+ return elements.relativeFeedback;
200
+ }
201
+ if (control === "repository") {
202
+ return elements.repositoryFeedback;
203
+ }
204
+ return elements.standaloneFeedback;
205
+ }
206
+
207
+ function setFeedback(control: WorkspaceFeedback, message = "", isError = false): void {
208
+ const element = feedbackElement(control);
209
+ element.textContent = message || "\u00a0";
210
+ element.classList.toggle("error", isError);
211
+ }
212
+
213
+ function clearFeedback(): void {
214
+ setFeedback("checkout");
215
+ setFeedback("relative");
216
+ setFeedback("repository");
217
+ setFeedback("standalone");
218
+ }
219
+
220
+ function worktreeHeadLabel(worktree: Pick<GitWorktreeSummary, "head">): string {
221
+ return worktree.head.type === "branch" ? worktree.head.name : `detached ${worktree.head.shortCommit}`;
222
+ }
223
+
224
+ function checkoutLabel(worktree: Pick<GitWorktreeSummary, "isLinkedWorktree">): string {
225
+ return worktree.isLinkedWorktree ? "Worktree" : "Primary checkout";
226
+ }
227
+
228
+ function compareText(left: string, right: string): number {
229
+ const folded = left.toLocaleLowerCase().localeCompare(right.toLocaleLowerCase());
230
+ return folded || left.localeCompare(right);
231
+ }
232
+
233
+ function modifiedTimestamp(session: Pick<SessionSummary, "modified">): number {
234
+ const timestamp = Date.parse(session.modified);
235
+ return Number.isNaN(timestamp) ? 0 : timestamp;
236
+ }
237
+
238
+ function compareLatestSessions(left: SessionSummary, right: SessionSummary): number {
239
+ return modifiedTimestamp(right) - modifiedTimestamp(left) || left.id.localeCompare(right.id);
240
+ }
241
+
242
+ function compareSessionsByTitle(left: SessionSummary, right: SessionSummary): number {
243
+ const title = compareText(displaySessionTitle(left), displaySessionTitle(right));
244
+ return title || modifiedTimestamp(right) - modifiedTimestamp(left) || left.id.localeCompare(right.id);
245
+ }
246
+
247
+ function compareWorktrees(left: GitWorktreeSummary, right: GitWorktreeSummary): number {
248
+ if (left.isLinkedWorktree !== right.isLinkedWorktree) {
249
+ return left.isLinkedWorktree ? 1 : -1;
250
+ }
251
+
252
+ return (
253
+ compareText(worktreeHeadLabel(left), worktreeHeadLabel(right)) || compareText(left.worktreeRoot, right.worktreeRoot)
254
+ );
255
+ }
256
+
257
+ function compareRelativePaths(left: string, right: string): number {
258
+ if (left === "." || right === ".") {
259
+ return left === "." ? -1 : 1;
260
+ }
261
+ return compareText(left, right);
262
+ }
263
+
264
+ function option(value: string, label: string): HTMLOptionElement {
265
+ const element = document.createElement("option");
266
+ element.value = value;
267
+ element.textContent = label;
268
+ return element;
269
+ }
270
+
271
+ function pathSeparator(path: string): string {
272
+ return path.includes("\\") && !path.includes("/") ? "\\" : "/";
273
+ }
274
+
275
+ function trimTrailingSeparators(path: string): string {
276
+ if (/^[A-Za-z]:[\\/]?$/.test(path)) {
277
+ return path;
278
+ }
279
+ return path.replace(/[\\/]+$/, "");
280
+ }
281
+
282
+ function joinServerPath(root: string, relativePath: string): string {
283
+ if (!relativePath || relativePath === ".") {
284
+ return root;
285
+ }
286
+
287
+ return `${trimTrailingSeparators(root)}${pathSeparator(root)}${relativePath}`;
288
+ }
289
+
290
+ function isAbsoluteServerPath(path: string): boolean {
291
+ return path.startsWith("/") || path.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(path);
292
+ }
293
+
294
+ function normalizeRelativePath(value: string, root: string): string {
295
+ const trimmed = value.trim();
296
+ if (!trimmed || trimmed === "." || trimmed.toLocaleLowerCase() === "(root)") {
297
+ return ".";
298
+ }
299
+ if (isAbsoluteServerPath(trimmed)) {
300
+ throw new Error("Working directory must be relative to the selected checkout.");
301
+ }
302
+
303
+ const segments = trimmed.split(/[\\/]+/).filter((segment) => segment && segment !== ".");
304
+ if (segments.includes("..")) {
305
+ throw new Error("Working directory cannot leave the selected checkout.");
306
+ }
307
+ return segments.join(pathSeparator(root)) || ".";
308
+ }
309
+
310
+ async function requestInspection(cwd: string): Promise<GitWorkspaceInspection> {
311
+ return api<GitWorkspaceInspection>("/api/git-workspaces/inspect", {
312
+ method: "POST",
313
+ headers: { "content-type": "application/json" },
314
+ body: JSON.stringify({ cwd }),
315
+ });
316
+ }
317
+
318
+ async function browseDirectories(path: string, signal: AbortSignal): Promise<DirectorySuggestionListing> {
319
+ return api<DirectorySuggestionListing>("/api/directories", {
320
+ method: "POST",
321
+ headers: { "content-type": "application/json" },
322
+ body: JSON.stringify({ path }),
323
+ signal,
324
+ });
325
+ }
326
+
327
+ function recentWorkspaceValue(): string {
328
+ if (workspaceKind === "repository" && repositorySelection) {
329
+ return `repository:${repositorySelection.repositoryRoot}`;
330
+ }
331
+ if (workspaceKind === "standalone" && standaloneSelection) {
332
+ return `standalone:${standaloneSelection.cwd}`;
333
+ }
334
+ return "";
335
+ }
336
+
337
+ function renderRecentWorkspaces(): void {
338
+ const choices = listing.groups.map((group) =>
339
+ group.type === "repository"
340
+ ? {
341
+ value: `repository:${group.repositoryRoot}`,
342
+ label: `Git · ${repositoryName(group.repositoryRoot)} — ${group.repositoryRoot}`,
343
+ }
344
+ : {
345
+ value: `standalone:${group.cwd}`,
346
+ label: `Directory · ${repositoryName(group.cwd)} — ${group.cwd}`,
347
+ },
348
+ );
349
+ const placeholder = listing.groups.length > 0 ? "Choose a recent workspace…" : "No recent workspaces";
350
+ const signature = JSON.stringify({ placeholder, choices });
351
+ if (signature !== recentWorkspaceOptionsSignature) {
352
+ elements.recentWorkspace.replaceChildren(
353
+ option("", placeholder),
354
+ ...choices.map((choice) => option(choice.value, choice.label)),
355
+ );
356
+ recentWorkspaceOptionsSignature = signature;
357
+ }
358
+
359
+ elements.recentWorkspace.value = pendingRecentWorkspaceValue ?? recentWorkspaceValue();
360
+ elements.recentWorkspace.disabled = creatingSession || listing.groups.length === 0;
361
+ }
362
+
363
+ function relativeShortcuts(): PathComboboxSuggestion[] {
364
+ const selection = repositorySelection;
365
+ if (!selection) {
366
+ return [];
367
+ }
368
+
369
+ const paths = ["."];
370
+ const group = listing.groups.find(
371
+ (candidate) => candidate.type === "repository" && candidate.repositoryRoot === selection.repositoryRoot,
372
+ );
373
+ if (group) {
374
+ for (const session of group.sessions) {
375
+ const relativeCwd = session.gitContext?.relativeCwd;
376
+ if (relativeCwd && !paths.includes(relativeCwd)) {
377
+ paths.push(relativeCwd);
378
+ }
379
+ }
380
+ }
381
+
382
+ return paths.map((path) => ({
383
+ value: displayRelativePath(path),
384
+ label: displayRelativePath(path),
385
+ detail: path === "." ? "Checkout root" : "Recent working directory",
386
+ source: "recent",
387
+ }));
388
+ }
389
+
390
+ function absoluteDirectorySuggestions(listing: DirectorySuggestionListing): PathComboboxSuggestion[] {
391
+ return listing.directories.map((directory) => ({
392
+ value: directory.path,
393
+ label: directory.name,
394
+ detail: directory.path,
395
+ source: "server",
396
+ }));
397
+ }
398
+
399
+ function primaryWorktree(inspection: GitWorkspaceInspection): GitWorktreeSummary | null {
400
+ return inspection.worktrees.find((worktree) => !worktree.isLinkedWorktree) ?? inspection.worktrees[0] ?? null;
401
+ }
402
+
403
+ function closeWorktreeEditor(): void {
404
+ newWorktreeOpen = false;
405
+ elements.worktreeName.value = "";
406
+ }
407
+
408
+ function currentCwd(): string {
409
+ return workspaceKind === "repository" ? (repositorySelection?.cwd ?? "") : (standaloneSelection?.cwd ?? "");
410
+ }
411
+
412
+ function renderCheckout(): void {
413
+ const selection = repositorySelection;
414
+ elements.checkoutSelect.replaceChildren();
415
+ if (!selection) {
416
+ elements.checkoutSelect.append(option("", "Choose a repository first"));
417
+ return;
418
+ }
419
+
420
+ for (const worktree of [...selection.repositoryInspection.worktrees].sort(compareWorktrees)) {
421
+ const label = worktree.isLinkedWorktree
422
+ ? `${worktreeHeadLabel(worktree)} — ${checkoutLabel(worktree)}`
423
+ : checkoutLabel(worktree);
424
+ elements.checkoutSelect.append(option(worktree.worktreeRoot, label));
425
+ }
426
+ elements.checkoutSelect.value = selection.worktreeRoot;
427
+ }
428
+
429
+ function renderWorktreeMode(): void {
430
+ const inspection = repositorySelection?.selectedInspection;
431
+ const repositoryActive = workspaceKind === "repository" && Boolean(repositorySelection);
432
+ elements.newWorktreeMode.hidden = !newWorktreeOpen || !repositoryActive;
433
+ elements.worktreeName.required = newWorktreeOpen && repositoryActive;
434
+ elements.worktreeStart.hidden = !repositoryActive || newWorktreeOpen;
435
+ elements.worktreeStart.disabled = workspacePending || !inspection?.creation.available;
436
+ elements.worktreeStart.setAttribute("aria-expanded", String(newWorktreeOpen));
437
+ elements.worktreeAvailability.textContent = inspection?.creation.reason ?? "";
438
+ elements.worktreeAvailability.hidden =
439
+ !repositoryActive || newWorktreeOpen || Boolean(inspection?.creation.available) || !inspection?.creation.reason;
440
+ elements.createSubmit.hidden = newWorktreeOpen && repositoryActive;
441
+ elements.worktreeSubmit.hidden = !newWorktreeOpen || !repositoryActive;
442
+ }
443
+
444
+ function renderWorktreeTarget(): void {
445
+ const context = repositorySelection?.selectedInspection.context;
446
+ elements.worktreeTarget.textContent = context ? worktreeTargetPath(context, elements.worktreeName.value.trim()) : "";
447
+ }
448
+
449
+ function renderNewSessionDisclosure(): void {
450
+ elements.newSession.hidden = !newSessionOpen;
451
+ elements.newSessionToggle.setAttribute("aria-expanded", String(newSessionOpen));
452
+ elements.newSessionToggle.disabled = creatingSession;
453
+ elements.newSessionCancel.disabled = creatingSession;
454
+ }
455
+
456
+ function renderWorkspaceControls(): void {
457
+ const repositoryActive = workspaceKind === "repository";
458
+ elements.kindRepository.checked = repositoryActive;
459
+ elements.kindStandalone.checked = !repositoryActive;
460
+ elements.repositoryFields.hidden = !repositoryActive;
461
+ elements.standaloneFields.hidden = repositoryActive;
462
+ elements.repositoryInput.required = repositoryActive;
463
+ elements.relativeInput.required = repositoryActive;
464
+ elements.standaloneInput.required = !repositoryActive;
465
+
466
+ renderRecentWorkspaces();
467
+ renderCheckout();
468
+ elements.checkoutSelect.disabled = workspacePending || !repositorySelection;
469
+ repositoryCombobox.setDisabled(workspacePending);
470
+ relativeCombobox.setDisabled(workspacePending || !repositorySelection);
471
+ standaloneCombobox.setDisabled(workspacePending);
472
+ renderWorktreeMode();
473
+ renderWorktreeTarget();
474
+
475
+ const validSelection = Boolean(currentCwd());
476
+ elements.createSubmit.disabled = creatingSession || workspacePending || !validSelection;
477
+ elements.worktreeSubmit.disabled =
478
+ creatingSession || workspacePending || !repositorySelection?.selectedInspection.creation.available;
479
+ elements.newSession.setAttribute("aria-busy", String(creatingSession || workspacePending));
480
+ renderNewSessionDisclosure();
481
+ }
482
+
483
+ function applyRepositorySelection(
484
+ repositoryInspection: GitWorkspaceInspection,
485
+ selectedInspection: GitWorkspaceInspection,
486
+ ): void {
487
+ const context = selectedInspection.context;
488
+ if (!context) {
489
+ throw new Error("This directory is not inside a Git repository.");
490
+ }
491
+
492
+ repositorySelection = {
493
+ repositoryRoot: context.repositoryRoot,
494
+ repositoryInspection,
495
+ selectedInspection,
496
+ worktreeRoot: context.worktreeRoot,
497
+ relativeCwd: context.relativeCwd,
498
+ cwd: selectedInspection.cwd,
499
+ };
500
+ workspaceKind = "repository";
501
+ elements.repositoryInput.value = context.repositoryRoot;
502
+ elements.relativeInput.value = displayRelativePath(context.relativeCwd);
503
+ closeWorktreeEditor();
504
+ }
505
+
506
+ async function inspectRepositoryPath(path: string, preserveDetectedPath = false): Promise<boolean> {
507
+ const requested = path.trim();
508
+ const generation = ++inspectionGeneration;
509
+ if (!requested) {
510
+ setFeedback("repository", "Enter an absolute repository path.", true);
511
+ return false;
512
+ }
513
+
514
+ if (!preserveDetectedPath) {
515
+ repositorySelection = null;
516
+ elements.relativeInput.value = "(root)";
517
+ closeWorktreeEditor();
518
+ setFeedback("checkout");
519
+ setFeedback("relative");
520
+ }
521
+
522
+ workspacePending = true;
523
+ setFeedback("repository", "Inspecting repository…");
524
+ setGitStatus("Inspecting repository…");
525
+ renderWorkspaceControls();
526
+
527
+ try {
528
+ const detectedInspection = await requestInspection(requested);
529
+ const detectedContext = detectedInspection.context;
530
+ if (!detectedContext) {
531
+ throw new Error("This directory is not inside a Git repository.");
532
+ }
533
+
534
+ const repositoryInspection =
535
+ detectedInspection.cwd === detectedContext.repositoryRoot
536
+ ? detectedInspection
537
+ : await requestInspection(detectedContext.repositoryRoot);
538
+ if (generation !== inspectionGeneration) {
539
+ return false;
540
+ }
541
+ if (
542
+ !repositoryInspection.context ||
543
+ repositoryInspection.context.repositoryRoot !== detectedContext.repositoryRoot
544
+ ) {
545
+ throw new Error("The repository checkout could not be inspected.");
546
+ }
547
+
548
+ let selectedInspection = detectedInspection;
549
+ if (!preserveDetectedPath) {
550
+ const primary = primaryWorktree(repositoryInspection);
551
+ if (!primary) {
552
+ throw new Error("No usable checkout was found for this repository.");
553
+ }
554
+ selectedInspection =
555
+ primary.cwd === repositoryInspection.cwd ? repositoryInspection : await requestInspection(primary.cwd);
556
+ }
557
+ if (generation !== inspectionGeneration) {
558
+ return false;
559
+ }
560
+
561
+ applyRepositorySelection(repositoryInspection, selectedInspection);
562
+ clearFeedback();
563
+ setFeedback("repository", detectedContext.repositoryRoot);
564
+ setGitStatus(`Git repository selected: ${detectedContext.repositoryRoot}.`);
565
+ return true;
566
+ } catch (error) {
567
+ if (generation !== inspectionGeneration) {
568
+ return false;
569
+ }
570
+
571
+ setFeedback("repository", readableError(error), true);
572
+ setGitStatus(readableError(error), true);
573
+ return false;
574
+ } finally {
575
+ if (generation === inspectionGeneration) {
576
+ workspacePending = false;
577
+ renderWorkspaceControls();
578
+ }
579
+ }
580
+ }
581
+
582
+ async function inspectStandalonePath(path: string): Promise<boolean> {
583
+ const requested = path.trim();
584
+ const generation = ++inspectionGeneration;
585
+ if (!requested) {
586
+ setFeedback("standalone", "Enter an absolute directory path.", true);
587
+ return false;
588
+ }
589
+
590
+ standaloneSelection = null;
591
+ closeWorktreeEditor();
592
+ workspacePending = true;
593
+ setFeedback("standalone", "Inspecting directory…");
594
+ setGitStatus("Inspecting directory…");
595
+ renderWorkspaceControls();
596
+
597
+ try {
598
+ const inspection = await requestInspection(requested);
599
+ if (generation !== inspectionGeneration) {
600
+ return false;
601
+ }
602
+
603
+ if (inspection.context) {
604
+ const repositoryInspection =
605
+ inspection.cwd === inspection.context.repositoryRoot
606
+ ? inspection
607
+ : await requestInspection(inspection.context.repositoryRoot);
608
+ if (generation !== inspectionGeneration) {
609
+ return false;
610
+ }
611
+
612
+ applyRepositorySelection(repositoryInspection, inspection);
613
+ clearFeedback();
614
+ setFeedback("repository", "Git repository detected. Switched from standalone directory.");
615
+ setGitStatus(`Git repository detected: ${inspection.context.repositoryRoot}.`);
616
+ return true;
617
+ }
618
+
619
+ standaloneSelection = { inspection, cwd: inspection.cwd };
620
+ workspaceKind = "standalone";
621
+ elements.standaloneInput.value = inspection.cwd;
622
+ closeWorktreeEditor();
623
+ clearFeedback();
624
+ setFeedback("standalone", inspection.cwd);
625
+ setGitStatus(`Standalone directory selected: ${inspection.cwd}.`);
626
+ return true;
627
+ } catch (error) {
628
+ if (generation !== inspectionGeneration) {
629
+ return false;
630
+ }
631
+
632
+ setFeedback("standalone", readableError(error), true);
633
+ setGitStatus(readableError(error), true);
634
+ return false;
635
+ } finally {
636
+ if (generation === inspectionGeneration) {
637
+ workspacePending = false;
638
+ renderWorkspaceControls();
639
+ }
640
+ }
641
+ }
642
+
643
+ async function inspectRelativePath(value: string): Promise<boolean> {
644
+ const selection = repositorySelection;
645
+ if (!selection) {
646
+ return false;
647
+ }
648
+
649
+ let relativeCwd: string;
650
+ try {
651
+ relativeCwd = normalizeRelativePath(value, selection.worktreeRoot);
652
+ } catch (error) {
653
+ setFeedback("relative", readableError(error), true);
654
+ return false;
655
+ }
656
+
657
+ const generation = ++inspectionGeneration;
658
+ workspacePending = true;
659
+ setFeedback("relative", "Inspecting working directory…");
660
+ renderWorkspaceControls();
661
+
662
+ try {
663
+ const inspection = await requestInspection(joinServerPath(selection.worktreeRoot, relativeCwd));
664
+ const context = inspection.context;
665
+ if (
666
+ !context ||
667
+ context.repositoryRoot !== selection.repositoryRoot ||
668
+ context.worktreeRoot !== selection.worktreeRoot
669
+ ) {
670
+ throw new Error("Working directory must be inside the selected checkout.");
671
+ }
672
+ if (generation !== inspectionGeneration) {
673
+ return false;
674
+ }
675
+
676
+ repositorySelection = {
677
+ ...selection,
678
+ selectedInspection: inspection,
679
+ relativeCwd: context.relativeCwd,
680
+ cwd: inspection.cwd,
681
+ };
682
+ elements.relativeInput.value = displayRelativePath(context.relativeCwd);
683
+ closeWorktreeEditor();
684
+ setFeedback("relative", inspection.cwd);
685
+ setGitStatus(`Working directory selected: ${inspection.cwd}.`);
686
+ return true;
687
+ } catch (error) {
688
+ if (generation !== inspectionGeneration) {
689
+ return false;
690
+ }
691
+
692
+ setFeedback("relative", readableError(error), true);
693
+ setGitStatus(readableError(error), true);
694
+ return false;
695
+ } finally {
696
+ if (generation === inspectionGeneration) {
697
+ workspacePending = false;
698
+ renderWorkspaceControls();
699
+ }
700
+ }
701
+ }
702
+
703
+ async function selectCheckout(): Promise<void> {
704
+ const selection = repositorySelection;
705
+ const worktree = selection?.repositoryInspection.worktrees.find(
706
+ (candidate) => candidate.worktreeRoot === elements.checkoutSelect.value,
707
+ );
708
+ if (!selection || !worktree) {
709
+ return;
710
+ }
711
+
712
+ const generation = ++inspectionGeneration;
713
+ workspacePending = true;
714
+ closeWorktreeEditor();
715
+ elements.relativeInput.value = "(root)";
716
+ repositorySelection = {
717
+ ...selection,
718
+ worktreeRoot: worktree.worktreeRoot,
719
+ relativeCwd: ".",
720
+ cwd: worktree.worktreeRoot,
721
+ };
722
+ setFeedback("relative");
723
+ setFeedback("checkout", "Inspecting checkout…");
724
+ renderWorkspaceControls();
725
+
726
+ try {
727
+ const inspection = await requestInspection(worktree.worktreeRoot);
728
+ if (generation !== inspectionGeneration) {
729
+ return;
730
+ }
731
+
732
+ const context = inspection.context;
733
+ if (
734
+ !context ||
735
+ context.repositoryRoot !== selection.repositoryRoot ||
736
+ context.worktreeRoot !== worktree.worktreeRoot
737
+ ) {
738
+ throw new Error("The selected checkout could not be inspected.");
739
+ }
740
+
741
+ repositorySelection = {
742
+ ...selection,
743
+ selectedInspection: inspection,
744
+ worktreeRoot: context.worktreeRoot,
745
+ relativeCwd: ".",
746
+ cwd: inspection.cwd,
747
+ };
748
+ elements.relativeInput.value = "(root)";
749
+ setFeedback("checkout", inspection.cwd);
750
+ setGitStatus(`Checkout selected: ${context.worktreeRoot}.`);
751
+ } catch (error) {
752
+ if (generation !== inspectionGeneration) {
753
+ return;
754
+ }
755
+
756
+ repositorySelection = selection;
757
+ elements.relativeInput.value = displayRelativePath(selection.relativeCwd);
758
+ setFeedback("checkout", readableError(error), true);
759
+ setGitStatus(readableError(error), true);
760
+ } finally {
761
+ if (generation === inspectionGeneration) {
762
+ workspacePending = false;
763
+ renderWorkspaceControls();
764
+ }
765
+ }
766
+ }
767
+
768
+ const repositoryCombobox = new PathCombobox({
769
+ input: elements.repositoryInput,
770
+ list: elements.repositoryList,
771
+ status: elements.repositoryStatus,
772
+ toggle: elements.repositoryToggle,
773
+ shortcuts: () => [],
774
+ load: async (value, signal) => absoluteDirectorySuggestions(await browseDirectories(value, signal)),
775
+ commit: (value) => inspectRepositoryPath(value),
776
+ });
777
+
778
+ const standaloneCombobox = new PathCombobox({
779
+ input: elements.standaloneInput,
780
+ list: elements.standaloneList,
781
+ status: elements.standaloneStatus,
782
+ toggle: elements.standaloneToggle,
783
+ shortcuts: () => [],
784
+ load: async (value, signal) => absoluteDirectorySuggestions(await browseDirectories(value, signal)),
785
+ commit: (value) => inspectStandalonePath(value),
786
+ });
787
+
788
+ const relativeCombobox = new PathCombobox({
789
+ input: elements.relativeInput,
790
+ list: elements.relativeList,
791
+ status: elements.relativeStatus,
792
+ toggle: elements.relativeToggle,
793
+ shortcuts: relativeShortcuts,
794
+ load: async (value, signal) => {
795
+ const selection = repositorySelection;
796
+ if (!selection) {
797
+ return [];
798
+ }
799
+
800
+ const normalized = normalizeRelativePath(value, selection.worktreeRoot);
801
+ const response = await browseDirectories(joinServerPath(selection.worktreeRoot, normalized), signal);
802
+ return response.directories.flatMap((directory): PathComboboxSuggestion[] => {
803
+ const relative = relativePathWithin(selection.worktreeRoot, directory.path);
804
+ return relative
805
+ ? [
806
+ {
807
+ value: displayRelativePath(relative),
808
+ label: displayRelativePath(relative),
809
+ detail: directory.path,
810
+ source: "server",
811
+ },
812
+ ]
813
+ : [];
814
+ });
815
+ },
816
+ commit: (value) => inspectRelativePath(value),
817
+ });
818
+
819
+ function sessionDeleteButton(session: SessionSummary): HTMLButtonElement {
820
+ const running = listing.runningSessionIds.includes(session.id);
821
+ const title = displaySessionTitle(session);
822
+ const button = document.createElement("button");
823
+ button.className = "session-delete destructive";
824
+ button.type = "button";
825
+ button.textContent = "Delete";
826
+ button.title = running ? "Wait for the session to finish before deleting it" : "Permanently delete session";
827
+ button.setAttribute("aria-label", `Permanently delete session ${title}`);
828
+ button.disabled = running || deletingSessionIds.has(session.id);
829
+ button.addEventListener("click", () => void deleteSession(session));
830
+ return button;
831
+ }
832
+
833
+ function renderSessionCard(session: SessionSummary): HTMLElement {
834
+ const running = listing.runningSessionIds.includes(session.id);
835
+ const title = displaySessionTitle(session);
836
+ const card = document.createElement("div");
837
+ card.className = "session-card";
838
+
839
+ const link = document.createElement("a");
840
+ link.className = "session-button";
841
+ link.href = sessionPath(session.id);
842
+ link.title = title;
843
+
844
+ const dot = document.createElement("span");
845
+ dot.className = `running-dot${running ? " active" : ""}`;
846
+ dot.setAttribute("aria-label", running ? "Running" : "Idle");
847
+
848
+ const copy = document.createElement("span");
849
+ copy.className = "session-copy";
850
+ copy.append(
851
+ textElement("strong", "", title),
852
+ textElement("span", "session-meta", `${formatTime(session.modified)} · ${session.messageCount} messages`),
853
+ );
854
+ link.append(dot, copy);
855
+
856
+ card.append(link, sessionDeleteButton(session));
857
+ return card;
858
+ }
859
+
860
+ function contextualCreateButton(cwd: string): HTMLButtonElement {
861
+ const button = document.createElement("button");
862
+ button.className = "contextual-session-create";
863
+ button.type = "button";
864
+ button.textContent = contextualCreationCwd === cwd ? "Creating…" : "+ New session";
865
+ button.title = `Create a new session in ${cwd}`;
866
+ button.setAttribute("aria-label", `Create a new session in ${cwd}`);
867
+ button.disabled = creatingSession;
868
+ button.addEventListener("click", () => void createContextualSession(cwd));
869
+ return button;
870
+ }
871
+
872
+ function repositoryWorktreeTarget(repositoryRoot: string, name: string): string {
873
+ const separator = pathSeparator(repositoryRoot);
874
+ return `${trimTrailingSeparators(repositoryRoot)}${separator}.pi${separator}worktrees${separator}${name || "<name>"}`;
875
+ }
876
+
877
+ function closeRepositoryWorktreeEditor(editorId: string): void {
878
+ repositoryWorktreeEditorRoot = null;
879
+ repositoryWorktreeName = "";
880
+ renderSessions();
881
+ document.getElementById(`${editorId}-toggle`)?.focus();
882
+ }
883
+
884
+ function repositoryWorktreeButton(group: RepositorySessionGroup, editorId: string): HTMLButtonElement {
885
+ const button = document.createElement("button");
886
+ button.id = `${editorId}-toggle`;
887
+ button.className = "repository-worktree-start";
888
+ button.type = "button";
889
+ button.textContent = "+ New worktree";
890
+ button.title = `Create a new worktree and session in ${group.repositoryRoot}`;
891
+ button.setAttribute("aria-label", `Create a new worktree and session in ${group.repositoryRoot}`);
892
+ button.setAttribute("aria-controls", editorId);
893
+ button.setAttribute("aria-expanded", String(repositoryWorktreeEditorRoot === group.repositoryRoot));
894
+ button.disabled = creatingSession;
895
+ button.addEventListener("click", () => {
896
+ if (repositoryWorktreeEditorRoot === group.repositoryRoot) {
897
+ document.querySelector<HTMLInputElement>(`#${editorId} input`)?.focus();
898
+ return;
899
+ }
900
+
901
+ repositoryWorktreeEditorRoot = group.repositoryRoot;
902
+ repositoryWorktreeName = "";
903
+ renderSessions();
904
+ document.querySelector<HTMLInputElement>(`#${editorId} input`)?.focus();
905
+ });
906
+ return button;
907
+ }
908
+
909
+ function renderRepositoryWorktreeEditor(group: RepositorySessionGroup, editorId: string): HTMLFormElement {
910
+ const form = document.createElement("form");
911
+ form.id = editorId;
912
+ form.className = "repository-worktree-editor";
913
+ form.setAttribute("aria-busy", String(repositoryWorktreeCreationRoot === group.repositoryRoot));
914
+
915
+ const header = document.createElement("header");
916
+ const headingId = `${editorId}-heading`;
917
+ const heading = textElement("h4", "", "New worktree");
918
+ heading.id = headingId;
919
+ form.setAttribute("aria-labelledby", headingId);
920
+
921
+ const cancel = document.createElement("button");
922
+ cancel.className = "quiet-action";
923
+ cancel.type = "button";
924
+ cancel.textContent = "Cancel";
925
+ cancel.disabled = creatingSession;
926
+ cancel.addEventListener("click", () => closeRepositoryWorktreeEditor(editorId));
927
+ header.append(heading, cancel);
928
+
929
+ const label = document.createElement("label");
930
+ const inputId = `${editorId}-name`;
931
+ label.htmlFor = inputId;
932
+ label.textContent = "Worktree and branch name";
933
+
934
+ const input = document.createElement("input");
935
+ input.id = inputId;
936
+ input.name = "worktreeName";
937
+ input.autocomplete = "off";
938
+ input.spellcheck = false;
939
+ input.required = true;
940
+ input.disabled = creatingSession;
941
+ input.value = repositoryWorktreeName;
942
+ input.addEventListener("input", () => {
943
+ repositoryWorktreeName = input.value;
944
+ target.textContent = repositoryWorktreeTarget(group.repositoryRoot, repositoryWorktreeName.trim());
945
+ });
946
+ input.addEventListener("keydown", (event) => {
947
+ if (event.key !== "Escape" || creatingSession) {
948
+ return;
949
+ }
950
+
951
+ event.preventDefault();
952
+ closeRepositoryWorktreeEditor(editorId);
953
+ });
954
+
955
+ const preview = document.createElement("p");
956
+ preview.className = "worktree-target";
957
+ preview.append("Create at ");
958
+ const target = document.createElement("span");
959
+ target.textContent = repositoryWorktreeTarget(group.repositoryRoot, repositoryWorktreeName.trim());
960
+ preview.append(target);
961
+
962
+ const submit = document.createElement("button");
963
+ submit.className = "session-create-submit";
964
+ submit.type = "submit";
965
+ submit.textContent =
966
+ repositoryWorktreeCreationRoot === group.repositoryRoot ? "Creating…" : "Create worktree & session";
967
+ submit.disabled = creatingSession;
968
+
969
+ form.append(header, label, input, preview, submit);
970
+ form.addEventListener("submit", (event) => {
971
+ event.preventDefault();
972
+ void createSessionAt(group.repositoryRoot, {
973
+ worktreeName: repositoryWorktreeName,
974
+ repositoryEditorRoot: group.repositoryRoot,
975
+ });
976
+ });
977
+ return form;
978
+ }
979
+
980
+ function renderGroupHeading(name: string, path: string, id: string, action?: HTMLElement): HTMLElement {
981
+ const header = document.createElement("header");
982
+ header.className = "session-group-header";
983
+ const identity = document.createElement("div");
984
+ identity.className = "session-group-identity";
985
+ const title = document.createElement("h3");
986
+ title.id = id;
987
+ title.title = path;
988
+ title.append(document.createTextNode(path.slice(0, Math.max(0, path.length - name.length))));
989
+ title.append(textElement("strong", "", name));
990
+ identity.append(title);
991
+ header.append(identity);
992
+ if (action) {
993
+ header.append(action);
994
+ }
995
+ return header;
996
+ }
997
+
998
+ function worktreeCwdLabel(group: RepositorySessionGroup, worktree: GitWorktreeSummary, relativeCwd: string): string {
999
+ if (!worktree.isLinkedWorktree) {
1000
+ return displayRelativePath(relativeCwd);
1001
+ }
1002
+
1003
+ const relativeWorktree = relativePathWithin(group.repositoryRoot, worktree.worktreeRoot);
1004
+ const worktreePath = relativeWorktree && relativeWorktree !== "." ? relativeWorktree : worktree.worktreeRoot;
1005
+ return relativeCwd === "." ? worktreePath : joinServerPath(worktreePath, relativeCwd);
1006
+ }
1007
+
1008
+ function renderWorktree(group: RepositorySessionGroup, worktree: GitWorktreeSummary): HTMLElement {
1009
+ const container = document.createElement("section");
1010
+ container.className = "worktree-group";
1011
+ const sessions = group.sessions.filter((session) => session.workspaceContext?.worktreeRoot === worktree.worktreeRoot);
1012
+
1013
+ const cwdHeading = (relativeCwd: string, includeDelete: boolean): HTMLElement => {
1014
+ const cwd = joinServerPath(worktree.worktreeRoot, relativeCwd);
1015
+ const header = document.createElement("header");
1016
+ header.className = "worktree-header";
1017
+ const identity = document.createElement("div");
1018
+ identity.className = "worktree-identity";
1019
+ identity.title = cwd;
1020
+ identity.append(textElement("h5", "relative-cwd-heading", worktreeCwdLabel(group, worktree, relativeCwd)));
1021
+
1022
+ const context = document.createElement("div");
1023
+ context.className = "worktree-heading-context";
1024
+ context.append(identity, contextualCreateButton(cwd));
1025
+ header.append(context);
1026
+
1027
+ if (includeDelete) {
1028
+ const active = sessions.some((session) => listing.activeSessionIds.includes(session.id));
1029
+ const removalPending = removingWorktreeRoots.size > 0;
1030
+ const remove = document.createElement("button");
1031
+ remove.className = "worktree-delete";
1032
+ remove.type = "button";
1033
+ remove.textContent = removingWorktreeRoots.has(worktree.worktreeRoot) ? "Deleting…" : "Delete worktree";
1034
+ remove.disabled = active || removalPending;
1035
+ remove.title = active
1036
+ ? "Wait for active sessions in this worktree to close before deleting it"
1037
+ : `Delete linked worktree at ${worktree.worktreeRoot}`;
1038
+ remove.setAttribute("aria-label", `Delete linked worktree ${worktreeHeadLabel(worktree)}`);
1039
+ remove.addEventListener("click", () => void removeWorktree(group, worktree));
1040
+ header.append(remove);
1041
+ }
1042
+
1043
+ return header;
1044
+ };
1045
+
1046
+ const cards = document.createElement("div");
1047
+ cards.className = "worktree-sessions";
1048
+ if (sessions.length === 0) {
1049
+ cards.append(
1050
+ cwdHeading(".", worktree.isLinkedWorktree),
1051
+ textElement("p", "worktree-empty", "No saved sessions in this worktree."),
1052
+ );
1053
+ } else {
1054
+ const sessionsByRelativeCwd = new Map<string, SessionSummary[]>();
1055
+ for (const session of sessions) {
1056
+ const relativeCwd = session.workspaceContext?.relativeCwd ?? ".";
1057
+ const relativeSessions = sessionsByRelativeCwd.get(relativeCwd) ?? [];
1058
+ relativeSessions.push(session);
1059
+ sessionsByRelativeCwd.set(relativeCwd, relativeSessions);
1060
+ }
1061
+
1062
+ const relativeGroups = [...sessionsByRelativeCwd].sort(([left], [right]) => compareRelativePaths(left, right));
1063
+ for (const [index, [relativeCwd, relativeSessions]] of relativeGroups.entries()) {
1064
+ const relativeGroup = document.createElement("section");
1065
+ relativeGroup.className = "relative-cwd-group";
1066
+ relativeGroup.append(
1067
+ cwdHeading(relativeCwd, worktree.isLinkedWorktree && index === 0),
1068
+ ...relativeSessions.sort(compareSessionsByTitle).map((session) => renderSessionCard(session)),
1069
+ );
1070
+ cards.append(relativeGroup);
1071
+ }
1072
+ }
1073
+ container.append(cards);
1074
+ return container;
1075
+ }
1076
+
1077
+ function allSessions(): SessionSummary[] {
1078
+ return listing.groups.flatMap((group) => group.sessions);
1079
+ }
1080
+
1081
+ function renderLatestSession(session: SessionSummary): HTMLElement {
1082
+ const running = listing.runningSessionIds.includes(session.id);
1083
+ const title = displaySessionTitle(session);
1084
+ const row = document.createElement("div");
1085
+ row.className = "latest-session-card";
1086
+
1087
+ const link = document.createElement("a");
1088
+ link.className = "latest-session-button";
1089
+ link.href = sessionPath(session.id);
1090
+ link.title = title;
1091
+
1092
+ const dot = document.createElement("span");
1093
+ dot.className = `running-dot${running ? " active" : ""}`;
1094
+ dot.setAttribute("aria-label", running ? "Running" : "Idle");
1095
+
1096
+ const copy = document.createElement("span");
1097
+ copy.className = "session-copy";
1098
+ copy.append(
1099
+ textElement("strong", "", title),
1100
+ textElement("span", "session-meta", `${formatTime(session.modified)} · ${session.messageCount} messages`),
1101
+ textElement("span", "latest-session-location", session.cwd),
1102
+ );
1103
+ link.append(dot, copy);
1104
+
1105
+ row.append(link, sessionDeleteButton(session));
1106
+ return row;
1107
+ }
1108
+
1109
+ function renderSessions(): void {
1110
+ const sessions = allSessions();
1111
+ const latest = sessions.sort(compareLatestSessions).slice(0, 5);
1112
+ elements.latestSessionList.replaceChildren(
1113
+ ...(latest.length > 0
1114
+ ? latest.map((session) => renderLatestSession(session))
1115
+ : [textElement("p", "empty-list latest-empty", "No saved sessions yet.")]),
1116
+ );
1117
+
1118
+ const groups = document.createDocumentFragment();
1119
+ const orderedGroups = [...listing.groups].sort((left, right) => compareText(left.cwd, right.cwd));
1120
+ for (const [index, group] of orderedGroups.entries()) {
1121
+ const section = document.createElement("section");
1122
+ section.className = `session-group ${group.type}`;
1123
+ const headingId = `session-group-${index}`;
1124
+ section.setAttribute("aria-labelledby", headingId);
1125
+
1126
+ if (group.type === "repository") {
1127
+ const editorId = `${headingId}-new-worktree`;
1128
+ section.append(
1129
+ renderGroupHeading(
1130
+ repositoryName(group.repositoryRoot),
1131
+ group.repositoryRoot,
1132
+ headingId,
1133
+ repositoryWorktreeButton(group, editorId),
1134
+ ),
1135
+ );
1136
+ if (repositoryWorktreeEditorRoot === group.repositoryRoot) {
1137
+ section.append(renderRepositoryWorktreeEditor(group, editorId));
1138
+ }
1139
+
1140
+ const worktrees = document.createElement("div");
1141
+ worktrees.className = "worktree-list";
1142
+ worktrees.append(
1143
+ ...[...group.worktrees].sort(compareWorktrees).map((worktree) => renderWorktree(group, worktree)),
1144
+ );
1145
+ section.append(worktrees);
1146
+ } else {
1147
+ section.append(
1148
+ renderGroupHeading(repositoryName(group.cwd), group.cwd, headingId, contextualCreateButton(group.cwd)),
1149
+ );
1150
+ const cards = document.createElement("div");
1151
+ cards.className = "standalone-sessions";
1152
+ cards.append(...[...group.sessions].sort(compareSessionsByTitle).map((session) => renderSessionCard(session)));
1153
+ section.append(cards);
1154
+ }
1155
+
1156
+ groups.append(section);
1157
+ }
1158
+
1159
+ if (orderedGroups.length === 0) {
1160
+ groups.append(textElement("p", "empty-list", "No saved sessions yet. Create one above to get started."));
1161
+ }
1162
+ elements.sessionList.replaceChildren(groups);
1163
+ renderRecentWorkspaces();
1164
+ relativeCombobox.refreshShortcuts();
1165
+ }
1166
+
1167
+ async function refreshSessions(showStatus = true, statusKey = "session-listing"): Promise<boolean> {
1168
+ try {
1169
+ listing = await api<SessionListing>("/api/sessions");
1170
+ renderSessions();
1171
+ if (showStatus) {
1172
+ clearAppStatus(statusKey);
1173
+ }
1174
+ return true;
1175
+ } catch (error) {
1176
+ setAppStatus(statusKey, readableError(error), "error");
1177
+ return false;
1178
+ }
1179
+ }
1180
+
1181
+ async function refreshRepositorySelectionAfterRemoval(
1182
+ repositoryRoot: string,
1183
+ removedWorktreeRoot: string,
1184
+ ): Promise<void> {
1185
+ const selection = repositorySelection;
1186
+ if (!selection || selection.repositoryRoot !== repositoryRoot) {
1187
+ return;
1188
+ }
1189
+
1190
+ const repositoryInspection = await requestInspection(repositoryRoot);
1191
+ if (!repositoryInspection.context) {
1192
+ repositorySelection = null;
1193
+ renderWorkspaceControls();
1194
+ return;
1195
+ }
1196
+
1197
+ if (selection.worktreeRoot !== removedWorktreeRoot) {
1198
+ repositorySelection = { ...selection, repositoryInspection };
1199
+ renderWorkspaceControls();
1200
+ return;
1201
+ }
1202
+
1203
+ const fallback = primaryWorktree(repositoryInspection);
1204
+ if (!fallback) {
1205
+ repositorySelection = null;
1206
+ renderWorkspaceControls();
1207
+ return;
1208
+ }
1209
+
1210
+ const selectedInspection =
1211
+ fallback.cwd === repositoryInspection.cwd ? repositoryInspection : await requestInspection(fallback.cwd);
1212
+ applyRepositorySelection(repositoryInspection, selectedInspection);
1213
+ renderWorkspaceControls();
1214
+ }
1215
+
1216
+ async function removeWorktree(group: RepositorySessionGroup, worktree: GitWorktreeSummary): Promise<void> {
1217
+ if (!worktree.isLinkedWorktree || removingWorktreeRoots.size > 0) {
1218
+ return;
1219
+ }
1220
+
1221
+ const label = worktreeHeadLabel(worktree);
1222
+ const statusKey = `worktree-removal:${worktree.worktreeRoot}`;
1223
+ const confirmed = confirm(
1224
+ `Delete linked worktree “${label}”?\n\nThis deletes the checkout at ${worktree.worktreeRoot}. Git will refuse if it contains modified or untracked files. Git branches and saved Pi sessions are kept.`,
1225
+ );
1226
+ if (!confirmed) {
1227
+ return;
1228
+ }
1229
+
1230
+ removingWorktreeRoots.add(worktree.worktreeRoot);
1231
+ renderSessions();
1232
+
1233
+ try {
1234
+ await api<{ removed: { worktreeRoot: string } }>("/api/git-workspaces/worktrees", {
1235
+ method: "DELETE",
1236
+ headers: { "content-type": "application/json" },
1237
+ body: JSON.stringify({ cwd: group.repositoryRoot, worktreeRoot: worktree.worktreeRoot }),
1238
+ });
1239
+ const [sessionsRefreshed] = await Promise.all([
1240
+ refreshSessions(false, statusKey),
1241
+ refreshRepositorySelectionAfterRemoval(group.repositoryRoot, worktree.worktreeRoot),
1242
+ ]);
1243
+ if (sessionsRefreshed) {
1244
+ setAppStatus(statusKey, `Deleted linked worktree at ${worktree.worktreeRoot}.`, "success");
1245
+ }
1246
+ } catch (error) {
1247
+ setAppStatus(statusKey, readableError(error), "error");
1248
+ } finally {
1249
+ removingWorktreeRoots.delete(worktree.worktreeRoot);
1250
+ renderSessions();
1251
+ }
1252
+ }
1253
+
1254
+ async function deleteSession(session: SessionSummary): Promise<void> {
1255
+ const title = displaySessionTitle(session);
1256
+ const statusKey = `session-deletion:${session.id}`;
1257
+ if (!confirm(`Permanently delete “${title}”? This cannot be undone.`)) {
1258
+ return;
1259
+ }
1260
+
1261
+ deletingSessionIds.add(session.id);
1262
+ renderSessions();
1263
+
1264
+ try {
1265
+ await api<{ deleted: boolean }>(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
1266
+ if (await refreshSessions(false, statusKey)) {
1267
+ setAppStatus(statusKey, `Session “${title}” deleted`, "success");
1268
+ }
1269
+ } catch (error) {
1270
+ setAppStatus(statusKey, readableError(error), "error");
1271
+ } finally {
1272
+ deletingSessionIds.delete(session.id);
1273
+ renderSessions();
1274
+ }
1275
+ }
1276
+
1277
+ async function commitEditedWorkspace(): Promise<boolean> {
1278
+ if (workspaceKind === "standalone") {
1279
+ return elements.standaloneInput.value.trim() === standaloneSelection?.cwd
1280
+ ? true
1281
+ : inspectStandalonePath(elements.standaloneInput.value);
1282
+ }
1283
+
1284
+ const requestedRepository = elements.repositoryInput.value.trim();
1285
+ if (requestedRepository !== repositorySelection?.repositoryRoot) {
1286
+ return inspectRepositoryPath(requestedRepository);
1287
+ }
1288
+ if (elements.relativeInput.value.trim() !== displayRelativePath(repositorySelection?.relativeCwd ?? ".")) {
1289
+ return inspectRelativePath(elements.relativeInput.value);
1290
+ }
1291
+ return true;
1292
+ }
1293
+
1294
+ async function createSessionAt(
1295
+ cwd: string,
1296
+ options: { worktreeName?: string; contextualCwd?: string; repositoryEditorRoot?: string } = {},
1297
+ ): Promise<void> {
1298
+ if (creatingSession) {
1299
+ return;
1300
+ }
1301
+
1302
+ creatingSession = true;
1303
+ contextualCreationCwd = options.contextualCwd ?? null;
1304
+ repositoryWorktreeCreationRoot = options.repositoryEditorRoot ?? null;
1305
+ renderWorkspaceControls();
1306
+ renderSessions();
1307
+
1308
+ try {
1309
+ const body = options.worktreeName ? { cwd, worktreeName: options.worktreeName } : { cwd };
1310
+ const response = await api<{ session: { id: string } }>("/api/sessions", {
1311
+ method: "POST",
1312
+ headers: { "content-type": "application/json" },
1313
+ body: JSON.stringify(body),
1314
+ });
1315
+ location.assign(sessionPath(response.session.id));
1316
+ } catch (error) {
1317
+ creatingSession = false;
1318
+ contextualCreationCwd = null;
1319
+ repositoryWorktreeCreationRoot = null;
1320
+ renderWorkspaceControls();
1321
+ renderSessions();
1322
+ setAppStatus("session-creation", readableError(error), "error");
1323
+ }
1324
+ }
1325
+
1326
+ async function createContextualSession(cwd: string): Promise<void> {
1327
+ await createSessionAt(cwd, { contextualCwd: cwd });
1328
+ }
1329
+
1330
+ async function createSession(): Promise<void> {
1331
+ if (creatingSession || workspacePending) {
1332
+ return;
1333
+ }
1334
+ if (!(await commitEditedWorkspace())) {
1335
+ return;
1336
+ }
1337
+
1338
+ const createWorktree = newWorktreeOpen && workspaceKind === "repository";
1339
+ const cwd = currentCwd();
1340
+ if (!cwd || !elements.newSession.reportValidity()) {
1341
+ return;
1342
+ }
1343
+
1344
+ await createSessionAt(cwd, createWorktree ? { worktreeName: elements.worktreeName.value } : {});
1345
+ }
1346
+
1347
+ function sourceSessionId(): string | null {
1348
+ const match = /^\/sessions\/([^/]+)\/new\/?$/.exec(location.pathname);
1349
+ if (!match?.[1]) {
1350
+ return null;
1351
+ }
1352
+
1353
+ try {
1354
+ return decodeURIComponent(match[1]);
1355
+ } catch {
1356
+ return null;
1357
+ }
1358
+ }
1359
+
1360
+ async function initializeWorkspace(): Promise<void> {
1361
+ if (workspaceInitialized) {
1362
+ return;
1363
+ }
1364
+ workspaceInitialized = true;
1365
+
1366
+ let initialCwd = "";
1367
+ let initialCwdFromSession = false;
1368
+ const sourceId = sourceSessionId();
1369
+ if (sourceId) {
1370
+ try {
1371
+ const response = await api<{ session: { cwd: string } }>(`/api/sessions/${encodeURIComponent(sourceId)}`);
1372
+ initialCwd = response.session.cwd;
1373
+ initialCwdFromSession = true;
1374
+ } catch (error) {
1375
+ setAppStatus("source-session", readableError(error), "error");
1376
+ }
1377
+ }
1378
+ if (!initialCwd) {
1379
+ initialCwd = listing.knownCwds[0] ?? "";
1380
+ }
1381
+ if (!initialCwd) {
1382
+ renderWorkspaceControls();
1383
+ return;
1384
+ }
1385
+
1386
+ const initialGeneration = ++inspectionGeneration;
1387
+ try {
1388
+ const inspection = await requestInspection(initialCwd);
1389
+ if (initialGeneration === inspectionGeneration && inspection.context) {
1390
+ elements.repositoryInput.value = inspection.context.repositoryRoot;
1391
+ await inspectRepositoryPath(initialCwd, initialCwdFromSession);
1392
+ } else if (initialGeneration === inspectionGeneration) {
1393
+ workspaceKind = "standalone";
1394
+ elements.standaloneInput.value = initialCwd;
1395
+ await inspectStandalonePath(initialCwd);
1396
+ }
1397
+ } catch (error) {
1398
+ if (initialGeneration === inspectionGeneration) {
1399
+ setAppStatus("initial-workspace", readableError(error), "error");
1400
+ }
1401
+ }
1402
+ renderWorkspaceControls();
1403
+ }
1404
+
1405
+ async function openNewSession(focusForm: boolean): Promise<void> {
1406
+ newSessionOpen = true;
1407
+ renderNewSessionDisclosure();
1408
+ await initializeWorkspace();
1409
+
1410
+ if (focusForm) {
1411
+ const target = elements.recentWorkspace.disabled
1412
+ ? workspaceKind === "repository"
1413
+ ? elements.repositoryInput
1414
+ : elements.standaloneInput
1415
+ : elements.recentWorkspace;
1416
+ target.focus();
1417
+ }
1418
+ }
1419
+
1420
+ function closeNewSession(): void {
1421
+ newSessionOpen = false;
1422
+ closeWorktreeEditor();
1423
+ renderWorkspaceControls();
1424
+ }
1425
+
1426
+ function changeWorkspaceKind(kind: WorkspaceKind): void {
1427
+ ++inspectionGeneration;
1428
+ workspacePending = false;
1429
+ pendingRecentWorkspaceValue = null;
1430
+ workspaceKind = kind;
1431
+ repositorySelection = null;
1432
+ standaloneSelection = null;
1433
+ elements.repositoryInput.value = "";
1434
+ elements.relativeInput.value = "(root)";
1435
+ elements.standaloneInput.value = "";
1436
+ closeWorktreeEditor();
1437
+ clearFeedback();
1438
+ setGitStatus("");
1439
+ renderWorkspaceControls();
1440
+ }
1441
+
1442
+ async function selectRecentWorkspace(): Promise<void> {
1443
+ const value = elements.recentWorkspace.value;
1444
+ if (!value) {
1445
+ pendingRecentWorkspaceValue = null;
1446
+ return;
1447
+ }
1448
+
1449
+ pendingRecentWorkspaceValue = value;
1450
+ repositorySelection = null;
1451
+ standaloneSelection = null;
1452
+ elements.repositoryInput.value = "";
1453
+ elements.relativeInput.value = "(root)";
1454
+ elements.standaloneInput.value = "";
1455
+ closeWorktreeEditor();
1456
+ try {
1457
+ if (value.startsWith("repository:")) {
1458
+ const repositoryRoot = value.slice("repository:".length);
1459
+ workspaceKind = "repository";
1460
+ elements.repositoryInput.value = repositoryRoot;
1461
+ clearFeedback();
1462
+ renderWorkspaceControls();
1463
+ await inspectRepositoryPath(repositoryRoot, false);
1464
+ return;
1465
+ }
1466
+ if (value.startsWith("standalone:")) {
1467
+ const cwd = value.slice("standalone:".length);
1468
+ workspaceKind = "standalone";
1469
+ elements.standaloneInput.value = cwd;
1470
+ clearFeedback();
1471
+ renderWorkspaceControls();
1472
+ await inspectStandalonePath(cwd);
1473
+ }
1474
+ } finally {
1475
+ if (pendingRecentWorkspaceValue === value) {
1476
+ pendingRecentWorkspaceValue = null;
1477
+ renderRecentWorkspaces();
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ elements.newSession.addEventListener("submit", (event) => {
1483
+ event.preventDefault();
1484
+ void createSession();
1485
+ });
1486
+ elements.newSessionToggle.addEventListener("click", () => void openNewSession(true));
1487
+ elements.newSessionCancel.addEventListener("click", () => {
1488
+ closeNewSession();
1489
+ elements.newSessionToggle.focus();
1490
+ });
1491
+ elements.kindRepository.addEventListener("change", () => {
1492
+ if (elements.kindRepository.checked) {
1493
+ changeWorkspaceKind("repository");
1494
+ }
1495
+ });
1496
+ elements.kindStandalone.addEventListener("change", () => {
1497
+ if (elements.kindStandalone.checked) {
1498
+ changeWorkspaceKind("standalone");
1499
+ }
1500
+ });
1501
+ elements.recentWorkspace.addEventListener("change", () => void selectRecentWorkspace());
1502
+ elements.repositoryInput.addEventListener("input", () => {
1503
+ if (elements.repositoryInput.value.trim() !== repositorySelection?.repositoryRoot) {
1504
+ ++inspectionGeneration;
1505
+ workspacePending = false;
1506
+ pendingRecentWorkspaceValue = null;
1507
+ repositorySelection = null;
1508
+ elements.relativeInput.value = "(root)";
1509
+ elements.recentWorkspace.value = "";
1510
+ closeWorktreeEditor();
1511
+ setFeedback("checkout");
1512
+ setFeedback("relative");
1513
+ renderWorkspaceControls();
1514
+ }
1515
+ });
1516
+ elements.standaloneInput.addEventListener("input", () => {
1517
+ if (elements.standaloneInput.value.trim() !== standaloneSelection?.cwd) {
1518
+ ++inspectionGeneration;
1519
+ workspacePending = false;
1520
+ pendingRecentWorkspaceValue = null;
1521
+ standaloneSelection = null;
1522
+ elements.recentWorkspace.value = "";
1523
+ closeWorktreeEditor();
1524
+ renderWorkspaceControls();
1525
+ }
1526
+ });
1527
+ elements.relativeInput.addEventListener("input", () => {
1528
+ if (newWorktreeOpen) {
1529
+ closeWorktreeEditor();
1530
+ renderWorkspaceControls();
1531
+ }
1532
+ });
1533
+ elements.checkoutSelect.addEventListener("change", () => void selectCheckout());
1534
+ elements.worktreeStart.addEventListener("click", () => {
1535
+ if (!repositorySelection?.selectedInspection.creation.available) {
1536
+ return;
1537
+ }
1538
+
1539
+ newWorktreeOpen = true;
1540
+ elements.worktreeName.value = "";
1541
+ renderWorkspaceControls();
1542
+ elements.worktreeName.focus();
1543
+ });
1544
+ elements.worktreeCancel.addEventListener("click", () => {
1545
+ closeWorktreeEditor();
1546
+ renderWorkspaceControls();
1547
+ elements.worktreeStart.focus();
1548
+ });
1549
+ elements.worktreeName.addEventListener("input", renderWorktreeTarget);
1550
+ elements.worktreeName.addEventListener("keydown", (event) => {
1551
+ if (event.key !== "Escape") {
1552
+ return;
1553
+ }
1554
+
1555
+ event.preventDefault();
1556
+ elements.worktreeCancel.click();
1557
+ });
1558
+ document.addEventListener("visibilitychange", () => {
1559
+ if (document.visibilityState === "visible") {
1560
+ void refreshSessions(false);
1561
+ }
1562
+ });
1563
+
1564
+ clearFeedback();
1565
+ renderWorkspaceControls();
1566
+ await refreshSessions();
1567
+ if (sourceSessionId()) {
1568
+ await openNewSession(false);
1569
+ }
1570
+
1571
+ setInterval(() => {
1572
+ if (document.visibilityState === "visible") {
1573
+ void refreshSessions(false);
1574
+ }
1575
+ }, 30_000);